@oar-sh/oar 0.9.10 → 0.9.11

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 (104) hide show
  1. package/CHANGELOG.md +314 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +95 -22
  5. package/server/claude-worker/claude-session-process.mjs +89 -11
  6. package/server/claude-worker/claude-session-worker.mjs +9 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
  8. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  9. package/server/db-schema.mjs +59 -2
  10. package/server/grok-worker/acp-client.mjs +2 -1
  11. package/server/grok-worker/acp-host-services.mjs +4 -3
  12. package/server/migrations/0013-previews.mjs +130 -0
  13. package/server/migrations/0014-conversation-groups.mjs +89 -0
  14. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  15. package/server/migrations/0016-share-access-log.mjs +110 -0
  16. package/server/public/app/about-ui.js +73 -0
  17. package/server/public/app/about-view.mjs +234 -0
  18. package/server/public/app/action-confirmations.js +31 -14
  19. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  20. package/server/public/app/api-client.js +251 -16
  21. package/server/public/app/ask-user-view.js +17 -15
  22. package/server/public/app/bootstrap.js +158 -32
  23. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  24. package/server/public/app/context-menu.mjs +90 -6
  25. package/server/public/app/conversation-group-drag.mjs +191 -0
  26. package/server/public/app/conversation-group-order.mjs +265 -0
  27. package/server/public/app/conversation-group-state.mjs +100 -0
  28. package/server/public/app/conversation-list-drag.mjs +449 -0
  29. package/server/public/app/conversation-turn-state.mjs +81 -0
  30. package/server/public/app/conversation-view.js +136 -93
  31. package/server/public/app/cwd-picker.js +1 -1
  32. package/server/public/app/hidden-messages-view.mjs +312 -0
  33. package/server/public/app/host-suspend-ui.js +7 -1
  34. package/server/public/app/journal-view.js +787 -22
  35. package/server/public/app/preview-cards.mjs +117 -31
  36. package/server/public/app/preview-command.mjs +10 -1
  37. package/server/public/app/relay-board-view.js +19 -6
  38. package/server/public/app/relay-restart-error.mjs +16 -0
  39. package/server/public/app/settings-modal.js +40 -8
  40. package/server/public/app/settings-tabs.js +1 -1
  41. package/server/public/app/share-format.mjs +49 -0
  42. package/server/public/app/share-preview-marks.mjs +167 -0
  43. package/server/public/app/share-state.js +109 -0
  44. package/server/public/app/shared-conversations-settings.js +480 -0
  45. package/server/public/app/socket-handlers.js +80 -14
  46. package/server/public/app/store.js +96 -30
  47. package/server/public/app/subagent-row.mjs +3 -3
  48. package/server/public/app/subagent-viewer.mjs +30 -6
  49. package/server/public/app/thinking-follow.js +0 -4
  50. package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
  51. package/server/public/app/transcript-jump-bottom.mjs +132 -21
  52. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  53. package/server/public/index.html +386 -15
  54. package/server/relay-tools.md +2 -1
  55. package/server/relay.mjs +2 -1
  56. package/server/repositories/conversation-group-repository.mjs +444 -0
  57. package/server/repositories/message-repository.mjs +1 -0
  58. package/server/repositories/session-repository.mjs +12 -37
  59. package/server/repositories/share-repository.mjs +322 -0
  60. package/server/repositories/share-visibility-statements.mjs +10 -0
  61. package/server/routes/about-routes.mjs +15 -0
  62. package/server/routes/cache-routes.mjs +4 -0
  63. package/server/routes/conversation-group-routes.mjs +171 -0
  64. package/server/routes/messages-routes.mjs +184 -65
  65. package/server/routes/preview-routes.mjs +9 -1
  66. package/server/routes/sessions-routes.mjs +771 -193
  67. package/server/server-runtime.mjs +316 -100
  68. package/server/services/about-service.mjs +461 -0
  69. package/server/services/background-task-store.mjs +128 -0
  70. package/server/services/background-work-view.mjs +228 -0
  71. package/server/services/cache-rebuild-service.mjs +30 -0
  72. package/server/services/delete-archive-service.mjs +4 -1
  73. package/server/services/detached-worker-watch-service.mjs +150 -0
  74. package/server/services/hidden-message-list.mjs +21 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/message-pin-list.mjs +1 -1
  77. package/server/services/preview-health-probe.mjs +8 -1
  78. package/server/services/preview-proxy-server.mjs +8 -4
  79. package/server/services/preview-registry-service.mjs +676 -26
  80. package/server/services/preview-static-handler.mjs +40 -7
  81. package/server/services/preview-store.mjs +274 -0
  82. package/server/services/rate-limit-service.mjs +12 -1
  83. package/server/services/relay-auth.mjs +183 -0
  84. package/server/services/relay-cli-launcher-service.mjs +3 -1
  85. package/server/services/relay-event-service.mjs +17 -0
  86. package/server/services/remote-relay-dispatcher.mjs +57 -5
  87. package/server/services/request-boundary.mjs +183 -0
  88. package/server/services/session-activity-tracker.mjs +227 -19
  89. package/server/services/session-worker-launch-service.mjs +190 -6
  90. package/server/services/session-worker-process-service.mjs +10 -6
  91. package/server/services/share-access-log-service.mjs +123 -0
  92. package/server/services/share-path-redaction.mjs +424 -0
  93. package/server/services/status-event-service.mjs +7 -3
  94. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  95. package/server/services/windows-autostart-service.mjs +6 -1
  96. package/server/services/windows-boot-autostart-service.mjs +207 -23
  97. package/shared/preview-tool-core.mjs +15 -5
  98. package/shared/remote-relay-contract.mjs +3 -1
  99. package/shared/share-path-privacy.mjs +30 -0
  100. package/shared/windows-system-tools.mjs +106 -0
  101. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  102. package/shared/worker-runtime/heartbeat.mjs +24 -0
  103. package/shared/worker-runtime/kept-posts.mjs +83 -0
  104. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -41,6 +41,7 @@ import { openAIReasoningEffortsForModel } from '../../shared/openai-reasoning.mj
41
41
  import { DEFAULT_CLAUDE_REASONING_EFFORTS } from '../services/provider-reasoning-effort.mjs';
42
42
  import { claudeBaseModelId, claudeLongContextModelId } from '../../shared/model-id.mjs';
43
43
  import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
44
+ import { normalizeBackgroundTaskDigest } from '../../shared/worker-runtime/background-task-digest.mjs';
44
45
  import { ANSWERED_ELSEWHERE_KIND, STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
45
46
  import { isLiveResend } from '../services/steer-resend.mjs';
46
47
  import { resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
@@ -57,9 +58,11 @@ import { RELAY_EVENT_MESSAGE_KIND } from '../../shared/relay-events.mjs';
57
58
  import { isRelayShutdownQueued, relayShutdownGateReply } from '../services/relay-shutdown-gate.mjs';
58
59
  import { createStaleAttemptLogLimiter } from '../services/stale-attempt-log-limiter.mjs';
59
60
  import { createSubagentTimelinePublisher } from '../services/subagent-viewer-rooms.mjs';
61
+ import { createLocalRequestCheck } from '../services/request-boundary.mjs';
60
62
  import { buildClaudeCloudDelivery, isClaudeCloudProviderType } from '../services/claude-cloud-session-service.mjs';
61
63
  import {
62
64
  admitRemoteRelayRequest,
65
+ readRemoteRelayRequest,
63
66
  findMentionedRemoteRelays,
64
67
  findOriginRemoteRelay,
65
68
  formatRemoteRelayOriginHint,
@@ -1775,6 +1778,7 @@ export function registerMessagesRoutes(app, deps) {
1775
1778
  getModelCatalogState,
1776
1779
  buildRelayReadyBannerData,
1777
1780
  backgroundTaskStore,
1781
+ backgroundWorkView = null,
1778
1782
  sendWorkerControl,
1779
1783
  ensureSessionId,
1780
1784
  touchCli,
@@ -1811,6 +1815,10 @@ export function registerMessagesRoutes(app, deps) {
1811
1815
  relayRestartOrchestrator,
1812
1816
  requestRelayShutdown,
1813
1817
  cancelRelayShutdown = null,
1818
+ // Truly local = loopback peer, no tunnel marker, no forwarding header
1819
+ // (services/request-boundary.mjs). The runtime passes the check built
1820
+ // with the configured marker headers.
1821
+ isTrulyLocalRequest = createLocalRequestCheck().isTrulyLocal,
1814
1822
  featureFlags,
1815
1823
  sessionWorkerRegistry,
1816
1824
  sessionWorkerSupervisor,
@@ -1833,6 +1841,9 @@ export function registerMessagesRoutes(app, deps) {
1833
1841
  // agent-sent messages, the inbound switch, and mention unlocks. Absent in
1834
1842
  // older wiring and most tests, where every remote-relay step is a no-op.
1835
1843
  remoteRelayInbound = null,
1844
+ // Session groups (repositories/conversation-group-repository.mjs): the
1845
+ // group stop route. Absent in older wiring and most tests.
1846
+ conversationGroups = null,
1836
1847
  // Relay event subscriptions (services/relay-event-service.mjs) and the
1837
1848
  // per-session activity they read (services/session-activity-tracker.mjs).
1838
1849
  // Absent in older wiring and most tests: every hook is then a no-op.
@@ -1936,21 +1947,6 @@ export function registerMessagesRoutes(app, deps) {
1936
1947
  || event.includes('blocked');
1937
1948
  }
1938
1949
 
1939
- function isLoopbackAddress(value) {
1940
- const text = String(value || '').trim().toLowerCase();
1941
- if (!text) return false;
1942
- return text === '127.0.0.1'
1943
- || text === '::1'
1944
- || text === '::ffff:127.0.0.1'
1945
- || text === '::ffff:7f00:1';
1946
- }
1947
-
1948
- function isLoopbackRequest(req) {
1949
- const socketAddress = req?.socket?.remoteAddress;
1950
- const ipAddress = req?.ip;
1951
- return isLoopbackAddress(socketAddress) || isLoopbackAddress(ipAddress);
1952
- }
1953
-
1954
1950
  function readBridgeIdentity(req) {
1955
1951
  return relayBridgeOwnerService?.normalizeIdentity?.({
1956
1952
  pid: req.headers['x-relay-process-pid'],
@@ -2966,36 +2962,54 @@ export function registerMessagesRoutes(app, deps) {
2966
2962
  if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
2967
2963
  const conversationId = String(req.params.conversationId || '').trim();
2968
2964
  const requestedMessageId = String(req.body?.messageId || req.body?.queueMessageId || '').trim();
2965
+ const outcome = requestTurnStop(conversationId, {
2966
+ requestedMessageId,
2967
+ clientId: String(req.body?.clientId || '').trim() || null,
2968
+ });
2969
+ if (!outcome.ok) return rejectSessionBinding(res, outcome.status, outcome.error);
2970
+ return res.json(outcome.body);
2971
+ });
2972
+
2973
+ /**
2974
+ * The work of POST /api/conversation/:id/cancel-turn: an `abort_turn`
2975
+ * control for the live turn. `{ ok: true, body }` with the route's answer,
2976
+ * or `{ ok: false, status, error }` when the conversation is unknown.
2977
+ */
2978
+ function requestTurnStop(conversationId, { requestedMessageId = '', clientId = null } = {}) {
2969
2979
  const sessionState = getConversationSessionState(conversationId);
2970
- if (!sessionState.ok) {
2971
- return rejectSessionBinding(res, sessionState.status, sessionState.error);
2972
- }
2980
+ if (!sessionState.ok) return { ok: false, status: sessionState.status, error: sessionState.error };
2973
2981
 
2974
2982
  // The live turn, not merely the newest processing row: a steered message
2975
2983
  // folded into the running turn is also `processing`, and Stop must target
2976
2984
  // the turn actually executing (which the worker interrupts as a whole).
2977
2985
  const queueRow = resolveLiveTurnQueueRow(conversationId) || null;
2978
2986
  if (!queueRow) {
2979
- return res.json({
2987
+ return {
2980
2988
  ok: true,
2981
- queued: false,
2982
- acknowledgement: 'no-active-turn',
2983
- requestedMessageId: requestedMessageId || null,
2984
- activeMessageId: null,
2985
- });
2989
+ body: {
2990
+ ok: true,
2991
+ queued: false,
2992
+ acknowledgement: 'no-active-turn',
2993
+ requestedMessageId: requestedMessageId || null,
2994
+ activeMessageId: null,
2995
+ },
2996
+ };
2986
2997
  }
2987
2998
 
2988
2999
  const activeMessageId = String(queueRow.id || '').trim();
2989
3000
  if (requestedMessageId && activeMessageId && requestedMessageId !== activeMessageId) {
2990
3001
  const requestedRow = stmts.findQById.get(requestedMessageId) || null;
2991
3002
  const requestedStatus = String(requestedRow?.status || '').trim().toLowerCase();
2992
- return res.json({
3003
+ return {
2993
3004
  ok: true,
2994
- queued: false,
2995
- acknowledgement: requestedStatus === 'processing' ? 'message-mismatch' : 'already-finished',
2996
- requestedMessageId,
2997
- activeMessageId,
2998
- });
3005
+ body: {
3006
+ ok: true,
3007
+ queued: false,
3008
+ acknowledgement: requestedStatus === 'processing' ? 'message-mismatch' : 'already-finished',
3009
+ requestedMessageId,
3010
+ activeMessageId,
3011
+ },
3012
+ };
2999
3013
  }
3000
3014
 
3001
3015
  const sdkSessionId = normalizeSessionWorkerId(
@@ -3004,33 +3018,39 @@ export function registerMessagesRoutes(app, deps) {
3004
3018
  || sessionState.conversationSdkSessionId,
3005
3019
  );
3006
3020
  if (!sdkSessionId) {
3007
- return res.json({
3021
+ return {
3008
3022
  ok: true,
3009
- queued: false,
3010
- acknowledgement: 'active-turn-unbound',
3011
- requestedMessageId: activeMessageId || requestedMessageId || null,
3012
- activeMessageId,
3013
- });
3023
+ body: {
3024
+ ok: true,
3025
+ queued: false,
3026
+ acknowledgement: 'active-turn-unbound',
3027
+ requestedMessageId: activeMessageId || requestedMessageId || null,
3028
+ activeMessageId,
3029
+ },
3030
+ };
3014
3031
  }
3015
3032
 
3016
3033
  const existing = findActiveRelayControlByQueueMessage.get(activeMessageId, 'abort_turn') || null;
3017
3034
  if (existing) {
3018
- return res.json({
3035
+ return {
3019
3036
  ok: true,
3020
- queued: true,
3021
- duplicate: true,
3022
- acknowledgement: 'already-requested',
3023
- requestedMessageId: activeMessageId || requestedMessageId || null,
3024
- activeMessageId,
3025
- control: formatRelayControlResponse(existing),
3026
- });
3037
+ body: {
3038
+ ok: true,
3039
+ queued: true,
3040
+ duplicate: true,
3041
+ acknowledgement: 'already-requested',
3042
+ requestedMessageId: activeMessageId || requestedMessageId || null,
3043
+ activeMessageId,
3044
+ control: formatRelayControlResponse(existing),
3045
+ },
3046
+ };
3027
3047
  }
3028
3048
 
3029
3049
  const now = new Date().toISOString();
3030
3050
  const controlId = uuidv4();
3031
3051
  const requestPayload = JSON.stringify({
3032
3052
  source: 'relay-ui',
3033
- requestedByClientId: String(req.body?.clientId || '').trim() || null,
3053
+ requestedByClientId: clientId || null,
3034
3054
  });
3035
3055
  insertRelayControlRequest.run(
3036
3056
  controlId,
@@ -3043,14 +3063,59 @@ export function registerMessagesRoutes(app, deps) {
3043
3063
  now,
3044
3064
  );
3045
3065
  const control = getRelayControlRequestById.get(controlId) || null;
3046
- return res.json({
3066
+ return {
3047
3067
  ok: true,
3048
- queued: true,
3049
- acknowledgement: 'stop-queued',
3050
- requestedMessageId: activeMessageId || requestedMessageId || null,
3051
- activeMessageId,
3052
- control: formatRelayControlResponse(control),
3053
- });
3068
+ body: {
3069
+ ok: true,
3070
+ queued: true,
3071
+ acknowledgement: 'stop-queued',
3072
+ requestedMessageId: activeMessageId || requestedMessageId || null,
3073
+ activeMessageId,
3074
+ control: formatRelayControlResponse(control),
3075
+ },
3076
+ };
3077
+ }
3078
+
3079
+ // POST /api/conversation-groups/:id/stop — "Stop all running" on a session
3080
+ // group's head row: a stop for every member's live turn, and a stop for
3081
+ // every stoppable background task. Tasks the panel shows no Stop button
3082
+ // for are listed as `unstoppable`. The owner's action only: a paired
3083
+ // relay's agent is refused.
3084
+ app.post('/api/conversation-groups/:id/stop', auth, (req, res) => {
3085
+ if (readRemoteRelayRequest(req, remoteRelayInbound).remote) {
3086
+ return res.status(403).json({ error: 'Session groups are managed by this relay\'s owner only.' });
3087
+ }
3088
+ const groupId = String(req.params.id || '').trim();
3089
+ const group = groupId ? conversationGroups?.getGroup?.(groupId) : null;
3090
+ if (!group) return res.status(404).json({ ok: false, error: 'Group not found' });
3091
+ const clientId = String(req.body?.clientId || '').trim() || null;
3092
+ const turns = [];
3093
+ const tasks = [];
3094
+ const unstoppable = [];
3095
+ for (const member of conversationGroups.membersOf(groupId)) {
3096
+ if (member.archived) continue;
3097
+ const outcome = requestTurnStop(member.id, { clientId });
3098
+ if (outcome.ok && outcome.body.queued === true) turns.push({ id: member.id, messageId: outcome.body.activeMessageId });
3099
+ const conversation = stmts.getConvAnyStatus?.get?.(member.id) || null;
3100
+ const sessionId = String(conversation?.sdk_session_id || member.id);
3101
+ const liveTasks = [...new Set([member.id, sessionId])].flatMap((key) => backgroundTaskStore?.get?.(key) || []);
3102
+ for (const task of liveTasks) {
3103
+ // The store holds what the publish route keeps: `taskId`.
3104
+ const taskId = String(task?.taskId || '').trim();
3105
+ if (!taskId) continue;
3106
+ if (task?.stoppable === false) {
3107
+ unstoppable.push({ id: member.id, taskId, reason: 'no-stop' });
3108
+ continue;
3109
+ }
3110
+ const sent = sendWorkerControl?.(sessionId, { type: 'stop_background_task', taskId });
3111
+ if (sent) tasks.push({ id: member.id, taskId });
3112
+ else unstoppable.push({ id: member.id, taskId, reason: 'worker-not-connected' });
3113
+ }
3114
+ }
3115
+ if (turns.length || tasks.length) {
3116
+ console.log(`[${ts()}] GROUP STOP ${groupId.slice(0, 12)} turns=${turns.length} tasks=${tasks.length}`);
3117
+ }
3118
+ return res.json({ ok: true, turns, tasks, unstoppable });
3054
3119
  });
3055
3120
 
3056
3121
  app.post('/api/conversation/:conversationId/subagent/:subagentRunId/cancel', auth, (req, res) => {
@@ -5313,6 +5378,7 @@ export function registerMessagesRoutes(app, deps) {
5313
5378
  const requesterSessionId = normalizeSessionWorkerId(requester?.sessionId);
5314
5379
  const settleFailedHandled = [];
5315
5380
  const settleFailedSkipped = [];
5381
+ let resyncBackgroundTasks = false;
5316
5382
  const activeQueueMessageId = String(req.body?.activeQueueMessageId || '').trim();
5317
5383
  // A persistent-process worker can hold several live rows at once (the
5318
5384
  // running turn plus a delivered message queued behind it plus a
@@ -5379,6 +5445,32 @@ export function registerMessagesRoutes(app, deps) {
5379
5445
  }
5380
5446
  }
5381
5447
  sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
5448
+ // The worker's digest of its live background-task ids against the set
5449
+ // held for it (keyed as the worker posts, by its session id). They
5450
+ // differ after a relay restart, which starts with no sets, and after a
5451
+ // lost post either way round: the answer then asks for the full set
5452
+ // again. A worker that sends no digest has no opinion and is never
5453
+ // asked; a relay that holds a set while the worker reports none asks
5454
+ // too, and the empty answer ends those tasks.
5455
+ // The process this beat comes from holds the session: a set post of
5456
+ // another process is a late one (see the task route).
5457
+ backgroundTaskStore?.noteHolder?.(requesterSessionId, normalizedRequesterPid);
5458
+ const taskDigest = normalizeBackgroundTaskDigest(req.body?.backgroundTasks);
5459
+ if (taskDigest && typeof backgroundTaskStore?.matches === 'function') {
5460
+ resyncBackgroundTasks = !backgroundTaskStore.matches(taskDigest, requesterSessionId, requesterConversationId);
5461
+ }
5462
+ // The placeholder for background work this runtime was never told the
5463
+ // details of (a worker that cannot resync yet): the pages hear when it
5464
+ // appears and when it goes without a set having replaced it.
5465
+ if (!resyncBackgroundTasks && backgroundWorkView) {
5466
+ const unreported = backgroundWorkView.hasUnreportedWork(requesterSessionId) === true;
5467
+ if (backgroundTaskStore?.announce?.(requesterSessionId, unreported)) {
5468
+ io.emit('background_tasks', {
5469
+ conversationId: requesterSessionId,
5470
+ tasks: backgroundWorkView.panelTasks(requesterSessionId, requesterConversationId),
5471
+ });
5472
+ }
5473
+ }
5382
5474
  // Consumed steers whose settle the worker could not save within its
5383
5475
  // retry budget (e.g. its writes keep failing): the relay fails them
5384
5476
  // itself with the worker's terminal error, never requeueing them.
@@ -5438,6 +5530,7 @@ export function registerMessagesRoutes(app, deps) {
5438
5530
  pendingCount,
5439
5531
  ...(settleFailedHandled.length ? { settleFailedHandled } : {}),
5440
5532
  ...(settleFailedSkipped.length ? { settleFailedSkipped } : {}),
5533
+ ...(resyncBackgroundTasks ? { resyncBackgroundTasks: true } : {}),
5441
5534
  });
5442
5535
  });
5443
5536
 
@@ -6421,10 +6514,10 @@ export function registerMessagesRoutes(app, deps) {
6421
6514
  return res.json({ ok: true, recovered: rows.length, maxAgeMs });
6422
6515
  });
6423
6516
 
6517
+ // Emptying the queue, restarting the relay and withdrawing a restart are
6518
+ // the user's own maintenance actions: the browser app calls them from a
6519
+ // phone, over the tunnel and over the LAN. The token is the boundary.
6424
6520
  app.post('/api/queue/empty', auth, (req, res) => {
6425
- if (!isLoopbackRequest(req)) {
6426
- return res.status(403).json({ error: 'Queue empty endpoint is localhost-only' });
6427
- }
6428
6521
  const rows = stmts.listQueueForPauseDrop.all();
6429
6522
  const droppedCount = rows.length;
6430
6523
  if (!droppedCount) {
@@ -6452,9 +6545,6 @@ export function registerMessagesRoutes(app, deps) {
6452
6545
  });
6453
6546
 
6454
6547
  app.post('/api/relay/shutdown', auth, (req, res) => {
6455
- if (!isLoopbackRequest(req)) {
6456
- return res.status(403).json({ error: 'Relay shutdown endpoint is localhost-only' });
6457
- }
6458
6548
  if (typeof requestRelayShutdown !== 'function') {
6459
6549
  return res.status(501).json({ error: 'Relay shutdown orchestration is unavailable' });
6460
6550
  }
@@ -6472,9 +6562,6 @@ export function registerMessagesRoutes(app, deps) {
6472
6562
  // Withdraw a queued (not yet started) shutdown/restart. Same trust boundary
6473
6563
  // as queueing one.
6474
6564
  app.post('/api/relay/shutdown/cancel', auth, (req, res) => {
6475
- if (!isLoopbackRequest(req)) {
6476
- return res.status(403).json({ error: 'Relay shutdown endpoint is localhost-only' });
6477
- }
6478
6565
  if (typeof cancelRelayShutdown !== 'function') {
6479
6566
  return res.status(501).json({ error: 'Relay shutdown orchestration is unavailable' });
6480
6567
  }
@@ -6483,9 +6570,11 @@ export function registerMessagesRoutes(app, deps) {
6483
6570
  return res.json({ ok: true, ...result });
6484
6571
  });
6485
6572
 
6573
+ // Called by the relay's own worker on this machine; nothing that came in
6574
+ // through a proxy or the tunnel may start a provider image request.
6486
6575
  app.post('/api/image-operations/:operationId/execute', auth, async (req, res) => {
6487
- if (!isLoopbackRequest(req)) {
6488
- return res.status(403).json({ error: 'Image operation execution is localhost-only' });
6576
+ if (!isTrulyLocalRequest(req)) {
6577
+ return res.status(403).json({ error: 'Image operation execution is reachable from this machine only' });
6489
6578
  }
6490
6579
  if (featureFlags?.IMAGE_CONVERSATION_CONTINUITY_ENABLED !== true) {
6491
6580
  return res.status(404).json({ error: 'Image continuity is not enabled' });
@@ -7560,12 +7649,21 @@ export function registerMessagesRoutes(app, deps) {
7560
7649
  const taskId = String(req.body?.taskId || '').trim();
7561
7650
  if (!conversationId || !taskId) return res.status(400).json({ error: 'Missing conversationId or taskId' });
7562
7651
  const exitCode = Number(req.body?.exitCode);
7652
+ const settledDescription = String(req.body?.description || '').trim().slice(0, 500);
7563
7653
  sessionActivityTracker?.noteTaskSettled?.(conversationId, {
7564
7654
  taskId,
7565
7655
  toolUseId: sanitizeSubagentRunId(req.body?.toolUseId) || null,
7566
7656
  status: String(req.body?.status || '').trim().slice(0, 40) || 'completed',
7567
7657
  summary: String(req.body?.summary || '').trim().slice(0, 500) || null,
7568
7658
  exitCode: Number.isInteger(exitCode) ? exitCode : null,
7659
+ // What the task was, from a worker that sends it: the end of a task
7660
+ // this runtime never knew (it ended while the relay was away) is
7661
+ // still an event then.
7662
+ ...(settledDescription ? {
7663
+ description: settledDescription,
7664
+ taskType: String(req.body?.taskType || '').trim().slice(0, 80),
7665
+ startedAt: Number(req.body?.startedAt) || null,
7666
+ } : {}),
7569
7667
  });
7570
7668
  relayEventService?.afterStateChange?.(conversationId);
7571
7669
  res.json({ ok: true });
@@ -7574,6 +7672,10 @@ export function registerMessagesRoutes(app, deps) {
7574
7672
  // POST /api/background-tasks — a session worker publishes its live
7575
7673
  // background-task set (REPLACE semantics, mirroring the SDK's
7576
7674
  // background_tasks_changed signal) so the composer panel tracks it live.
7675
+ // `resync: true` marks the set as the answer to a heartbeat that asked for
7676
+ // it again (`resyncBackgroundTasks`): the same replace, and the pages get
7677
+ // the set again, but a task the relay had forgotten is not a new start.
7678
+ // `order` lets the relay drop a set that a newer one overtook.
7577
7679
  app.post('/api/background-tasks', auth, (req, res) => {
7578
7680
  touchCli();
7579
7681
  const conversationId = String(req.body?.conversationId || '').trim();
@@ -7602,13 +7704,30 @@ export function registerMessagesRoutes(app, deps) {
7602
7704
  toolUseId: sanitizeSubagentRunId(task?.toolUseId) || undefined,
7603
7705
  }))
7604
7706
  .filter((task) => task.taskId);
7707
+ const resync = req.body?.resync === true;
7708
+ // Posts can overtake each other on the way (an ordinary publish and the
7709
+ // answer to a resync, two publishes): a worker numbers its sets
7710
+ // (`order: { epoch, seq }`), and one older than the newest applied is
7711
+ // dropped, or a task of the newer set would read as ended. A worker
7712
+ // that numbers none is taken as it comes.
7713
+ // A drop is never silent: it is logged, and the answer says `stale`,
7714
+ // on which the worker asks for a heartbeat (which makes it the holder
7715
+ // of the session if it was not yet, and has the relay ask for the set).
7716
+ const setOrder = backgroundTaskStore?.acceptOrder?.(conversationId, req.body?.order, {
7717
+ pid: readBridgeIdentity(req)?.pid,
7718
+ });
7719
+ if (setOrder && setOrder.accepted === false) {
7720
+ console.log(`[${typeof ts === 'function' ? ts() : new Date().toISOString()}] BG-TASKS set dropped (${setOrder.reason}) session=${conversationId.slice(0, 8)} epoch=${Number(req.body?.order?.epoch) || 0} seq=${Number(req.body?.order?.seq) || 0} tasks=${tasks.length}`);
7721
+ return res.json({ ok: true, count: tasks.length, stale: true, reason: setOrder.reason });
7722
+ }
7605
7723
  backgroundTaskStore?.replace?.(conversationId, tasks);
7724
+ backgroundTaskStore?.announce?.(conversationId, false);
7606
7725
  io.emit('background_tasks', { conversationId, tasks });
7607
7726
  // The store is keyed as the worker posts (its SDK session id); the
7608
7727
  // tracker by the conversation.
7609
7728
  try {
7610
7729
  const trackedConversationId = resolveTaskConversationId(conversationId);
7611
- sessionActivityTracker?.noteTaskSet?.(trackedConversationId, tasks);
7730
+ sessionActivityTracker?.noteTaskSet?.(trackedConversationId, tasks, { resync });
7612
7731
  relayEventService?.afterStateChange?.(trackedConversationId);
7613
7732
  } catch {}
7614
7733
  // The panel's copy of a task lives in memory and goes when the task
@@ -7631,7 +7750,7 @@ export function registerMessagesRoutes(app, deps) {
7631
7750
  .some((column) => before[column] !== after[column]);
7632
7751
  if (changed) emitSubagentStatus(after);
7633
7752
  }
7634
- res.json({ ok: true, count: tasks.length });
7753
+ res.json({ ok: true, count: tasks.length, ...(resync ? { resync: true } : {}) });
7635
7754
  });
7636
7755
 
7637
7756
  // POST /api/conversation/:conversationId/background-task/:taskId/stop —
@@ -95,7 +95,15 @@ export function registerPreviewRoutes(app, deps) {
95
95
  // Probe immediately so the card does not sit badge-less until the next
96
96
  // scheduled sweep — and so a typo'd port shows as offline right away.
97
97
  previewHealthProbe?.probeNow?.(created.preview.token);
98
- res.json({ ok: true, preview: created.preview, url: created.preview.url, basePath: created.preview.basePath });
98
+ // `existing`: this conversation already had a preview of the same target,
99
+ // and this is it (same token, same link, label unchanged).
100
+ res.json({
101
+ ok: true,
102
+ existing: created.existing === true,
103
+ preview: created.preview,
104
+ url: created.preview.url,
105
+ basePath: created.preview.basePath,
106
+ });
99
107
  });
100
108
 
101
109
  // DELETE /api/previews/:token — the link stops resolving immediately. The dev