@oar-sh/oar 0.9.3 → 0.9.5

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 (76) 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 +98 -0
  4. package/README.md +76 -14
  5. package/package.json +5 -2
  6. package/server/README.md +43 -3
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +100 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  9. package/server/claude-worker/claude-session-process.mjs +385 -43
  10. package/server/claude-worker/claude-session-worker.mjs +5 -0
  11. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  12. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  13. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
  15. package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
  16. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  17. package/server/cursor-worker/cursor-session-worker.mjs +5 -0
  18. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  19. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  20. package/server/db-schema.mjs +8 -0
  21. package/server/grok-worker/acp-client.mjs +92 -6
  22. package/server/grok-worker/grok-session-worker.mjs +5 -0
  23. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  24. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  25. package/server/mcp/oar-mcp-server.mjs +473 -0
  26. package/server/migrations/0005-remote-relays.mjs +90 -0
  27. package/server/public/app/action-confirmations.js +141 -13
  28. package/server/public/app/api-client.js +103 -0
  29. package/server/public/app/bootstrap.js +21 -6
  30. package/server/public/app/conversation-deep-link.mjs +42 -0
  31. package/server/public/app/conversation-view.js +100 -7
  32. package/server/public/app/host-suspend-ui.js +272 -0
  33. package/server/public/app/journal-view.js +4 -1
  34. package/server/public/app/mention-autocomplete.mjs +298 -0
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/pwa-install.js +8 -3
  37. package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
  38. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  39. package/server/public/app/remote-relay-shared.mjs +85 -0
  40. package/server/public/app/remote-relays-settings.js +618 -0
  41. package/server/public/app/remote-relays-store.mjs +126 -0
  42. package/server/public/app/settings-modal.js +4 -2
  43. package/server/public/app/settings-tabs.js +1 -1
  44. package/server/public/app/socket-handlers.js +18 -1
  45. package/server/public/app/steered-turn-merge.mjs +10 -1
  46. package/server/public/app/thinking-follow.js +260 -0
  47. package/server/public/app/tool-activity-emoji.mjs +4 -0
  48. package/server/public/index.html +264 -11
  49. package/server/relay-tools.md +5 -1
  50. package/server/repositories/message-repository.mjs +7 -0
  51. package/server/repositories/question-repository.mjs +3 -0
  52. package/server/repositories/remote-relay-repository.mjs +75 -0
  53. package/server/repositories/session-repository.mjs +6 -1
  54. package/server/routes/ask-user-routes.mjs +42 -2
  55. package/server/routes/messages-routes.mjs +98 -19
  56. package/server/routes/remote-relay-routes.mjs +133 -0
  57. package/server/routes/sessions-routes.mjs +84 -31
  58. package/server/server-runtime.mjs +238 -0
  59. package/server/services/github-ci-activity-service.mjs +199 -0
  60. package/server/services/host-suspend-command.mjs +38 -0
  61. package/server/services/host-suspend-service.mjs +254 -0
  62. package/server/services/push-dispatch-service.mjs +37 -4
  63. package/server/services/remote-relay-caller-context.mjs +346 -0
  64. package/server/services/remote-relay-client.mjs +332 -0
  65. package/server/services/remote-relay-dispatcher.mjs +1743 -0
  66. package/server/services/remote-relay-inbound.mjs +187 -0
  67. package/server/services/remote-relay-pairing.mjs +189 -0
  68. package/server/services/remote-relay-registry-service.mjs +581 -0
  69. package/server/services/session-worker-launch-service.mjs +148 -14
  70. package/shared/remote-relay-contract.mjs +556 -0
  71. package/shared/remote-relay-mentions.mjs +129 -0
  72. package/shared/remote-relay-tool-core.mjs +314 -0
  73. package/shared/steer-settle-failure.mjs +6 -0
  74. package/shared/steer-settle-markers.mjs +16 -0
  75. package/shared/worker-runtime/api-client.mjs +112 -22
  76. package/shared/worker-runtime/worker-log-file.mjs +97 -0
@@ -37,9 +37,17 @@ import { openAIReasoningEffortsForModel } from '../../shared/openai-reasoning.mj
37
37
  import { DEFAULT_CLAUDE_REASONING_EFFORTS } from '../services/provider-reasoning-effort.mjs';
38
38
  import { claudeBaseModelId, claudeLongContextModelId } from '../../shared/model-id.mjs';
39
39
  import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
40
- import { STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
40
+ import { ANSWERED_ELSEWHERE_KIND, STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
41
41
  import { isLiveResend } from '../services/steer-resend.mjs';
42
42
  import { resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
43
+ import { stripRemotePromptHeader } from '../../shared/remote-relay-contract.mjs';
44
+ import {
45
+ admitRemoteRelayRequest,
46
+ findMentionedRemoteRelays,
47
+ formatRemoteRelayMentionHint,
48
+ recordRemoteRelayUnlocks,
49
+ withRemoteRelayMentionHint,
50
+ } from '../services/remote-relay-inbound.mjs';
43
51
 
44
52
  export const SESSION_WORKER_OWNER_LEASE_MS = 120_000;
45
53
  export const SESSION_WORKER_TRANSIENT_DEQUEUE_RETRIES = 2;
@@ -1784,6 +1792,7 @@ export function registerMessagesRoutes(app, deps) {
1784
1792
  relayBridgeOwnerService,
1785
1793
  relayRestartOrchestrator,
1786
1794
  requestRelayShutdown,
1795
+ cancelRelayShutdown = null,
1787
1796
  featureFlags,
1788
1797
  sessionWorkerRegistry,
1789
1798
  sessionWorkerSupervisor,
@@ -1801,6 +1810,10 @@ export function registerMessagesRoutes(app, deps) {
1801
1810
  opaqueResponseRecoveryPollMs = OPAQUE_RESPONSE_RECOVERY_POLL_MS,
1802
1811
  relayQuestionFinalizationHoldMs = RELAY_QUESTION_FINALIZATION_HOLD_MS,
1803
1812
  imageOperationService = null,
1813
+ // Remote relays (server/services/remote-relay-inbound.mjs): origin on
1814
+ // agent-sent messages, the inbound switch, and mention unlocks. Absent in
1815
+ // older wiring and most tests, where every remote-relay step is a no-op.
1816
+ remoteRelayInbound = null,
1804
1817
  } = deps;
1805
1818
 
1806
1819
  const ttyConsoleActive = runtimeState?.ttyConsoleActive === true;
@@ -1952,6 +1965,16 @@ export function registerMessagesRoutes(app, deps) {
1952
1965
  };
1953
1966
  }
1954
1967
 
1968
+ // The stored preferences, for a send that must leave them alone.
1969
+ function readConversationModelPreference(conversationId, fallbackRelayMode = DEFAULT_RELAY_MODE) {
1970
+ const row = stmts.getConvAnyStatus?.get?.(String(conversationId || '').trim()) || null;
1971
+ return {
1972
+ preferredRelayMode: normalizeRelayMode(row?.preferred_relay_mode) || fallbackRelayMode || DEFAULT_RELAY_MODE,
1973
+ preferredModel: String(row?.preferred_model || '').trim(),
1974
+ preferredReasoningEffort: String(row?.preferred_reasoning_effort || '').trim(),
1975
+ };
1976
+ }
1977
+
1955
1978
  function getConversationSessionState(conversationId) {
1956
1979
  const normalizedConversationId = String(conversationId || '').trim();
1957
1980
  if (!normalizedConversationId) {
@@ -2081,7 +2104,7 @@ export function registerMessagesRoutes(app, deps) {
2081
2104
  },
2082
2105
  });
2083
2106
  io.emit('message_status', { messageId, conversationId, status: 'failed' });
2084
- void pushDispatchService?.notifyTurnFailed?.({ conversationId, messageId: responseId, text: responseText });
2107
+ void pushDispatchService?.notifyTurnFailed?.({ conversationId, messageId: responseId, sourceMessageId: messageId, text: responseText });
2085
2108
  cancelPendingRelayQuestionsForMessage(messageId);
2086
2109
  const ownerSessionId = isSessionWorkerRoutingEnabled(featureFlags)
2087
2110
  ? normalizeSessionWorkerId(queueRow?.owner_sdk_session_id)
@@ -2824,6 +2847,7 @@ export function registerMessagesRoutes(app, deps) {
2824
2847
  });
2825
2848
 
2826
2849
  app.post('/api/conversation/:conversationId/cancel-turn', auth, (req, res) => {
2850
+ if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
2827
2851
  const conversationId = String(req.params.conversationId || '').trim();
2828
2852
  const requestedMessageId = String(req.body?.messageId || req.body?.queueMessageId || '').trim();
2829
2853
  const sessionState = getConversationSessionState(conversationId);
@@ -3072,6 +3096,7 @@ export function registerMessagesRoutes(app, deps) {
3072
3096
  }
3073
3097
 
3074
3098
  app.post('/api/conversation/:conversationId/cancel-queued-turn', auth, (req, res) => {
3099
+ if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
3075
3100
  const conversationId = String(req.params.conversationId || '').trim();
3076
3101
  const requestedMessageId = String(req.body?.messageId || '').trim();
3077
3102
  if (!conversationId) {
@@ -4053,6 +4078,12 @@ export function registerMessagesRoutes(app, deps) {
4053
4078
  attachments: rawAttachments,
4054
4079
  imageTarget: rawImageTarget,
4055
4080
  } = req.body;
4081
+ // A prompt from another relay's agent (body `origin` or the
4082
+ // X-OAR-Remote-Origin header) is refused while the inbound switch is off.
4083
+ // Otherwise its provenance is stored with the message, and it never
4084
+ // unlocks relays or touches the composer draft and preferences.
4085
+ const remoteRequest = admitRemoteRelayRequest(req, res, remoteRelayInbound);
4086
+ if (!remoteRequest) return;
4056
4087
  const sessionId = clientId || ensureSessionId(req, res);
4057
4088
  const requesterIdentity = readBridgeIdentity(req);
4058
4089
  const requesterSessionId = normalizeSessionWorkerId(requesterIdentity?.sessionId);
@@ -4547,7 +4578,12 @@ export function registerMessagesRoutes(app, deps) {
4547
4578
  });
4548
4579
  }
4549
4580
  const resendOriginId = resend?.originId || null;
4550
- if (!shouldCreateConversation) {
4581
+ // Another relay's agent sends every prompt with a fresh messageId, and a
4582
+ // retry of the same send hits the messageId check at insert. The text
4583
+ // window below would instead swallow a deliberate repeat ("run the tests
4584
+ // again"), which the agent cannot tell from a lost send.
4585
+ const exemptFromTextDuplicateCheck = remoteRequest.remote && !!clientMessageId;
4586
+ if (!shouldCreateConversation && !exemptFromTextDuplicateCheck) {
4551
4587
  const recentUserMessages = stmts.getRecentMessagesDesc.all(conversationId, 20);
4552
4588
  const recentQueueRows = db.prepare(`
4553
4589
  SELECT id, text, status, timestamp
@@ -4577,7 +4613,10 @@ export function registerMessagesRoutes(app, deps) {
4577
4613
 
4578
4614
  const convId = shouldCreateConversation ? uuidv4() : conversationId;
4579
4615
  if (shouldCreateConversation) {
4580
- getOrCreateConversation(convId, trimmedText || attachmentSummary(attachments) || 'Image');
4616
+ // An agent's prompt opens with the sending relay's header line, which
4617
+ // says who is talking, not what the conversation is about.
4618
+ const titleText = remoteRequest.remote ? stripRemotePromptHeader(trimmedText).trim() : trimmedText;
4619
+ getOrCreateConversation(convId, titleText || attachmentSummary(attachments) || 'Image');
4581
4620
  }
4582
4621
  let conversationWorkspaceRootState = null;
4583
4622
  if (workspaceRootUpdate.changed && workspaceRootUpdate.resolvedPath) {
@@ -4637,7 +4676,7 @@ export function registerMessagesRoutes(app, deps) {
4637
4676
  isNewConversation: shouldCreateConversation,
4638
4677
  });
4639
4678
  const msgId = clientMessageId || uuidv4();
4640
- const queueText = shouldApplySeed
4679
+ const promptText = shouldApplySeed
4641
4680
  ? [
4642
4681
  '[Carry-over context from previous compacted conversation]',
4643
4682
  String(convSeed.summary_seed).trim(),
@@ -4646,6 +4685,16 @@ export function registerMessagesRoutes(app, deps) {
4646
4685
  trimmedText || '(User sent image attachments only.)',
4647
4686
  ].join('\n')
4648
4687
  : trimmedText;
4688
+ // Only what a human typed unlocks a remote relay (decision 19). The hint
4689
+ // rides in the queued prompt only, so the stored message and the user's
4690
+ // bubble stay exactly what was typed.
4691
+ const mentionedRemoteRelays = remoteRequest.remote
4692
+ ? []
4693
+ : findMentionedRemoteRelays(remoteRelayInbound, trimmedText);
4694
+ const queueText = withRemoteRelayMentionHint(promptText, formatRemoteRelayMentionHint(mentionedRemoteRelays));
4695
+ // A Resend never came from the composer, and neither did another relay's
4696
+ // agent: whatever the user is typing there stays their draft.
4697
+ const keepsComposerDraft = !!resendOriginId || remoteRequest.remote;
4649
4698
 
4650
4699
  const requestedModelOrigin = deriveModelOrigin(requestedModelVariantId || requestedModel);
4651
4700
  let conversationPreferences;
@@ -4664,12 +4713,11 @@ export function registerMessagesRoutes(app, deps) {
4664
4713
  null,
4665
4714
  requestedModelOrigin,
4666
4715
  );
4716
+ if (remoteRequest.origin) remoteRelayInbound?.repository?.setMessageOrigin?.(msgId, remoteRequest.origin);
4667
4717
  linkUploadReferences(convId, msgId, attachments);
4668
4718
  stmts.updateConvTime.run(now, convId);
4669
4719
  if (resendOriginId) stmts.setMessageResendOf?.run(resendOriginId, msgId);
4670
- // A Resend never came from the composer: whatever the user is typing
4671
- // there stays their draft.
4672
- if (!resendOriginId) {
4720
+ if (!keepsComposerDraft) {
4673
4721
  if (typeof stmts.updateConvDraft?.run === 'function') {
4674
4722
  stmts.updateConvDraft.run(null, now, sessionId || null, convId);
4675
4723
  } else {
@@ -4716,13 +4764,17 @@ export function registerMessagesRoutes(app, deps) {
4716
4764
  ) {
4717
4765
  stmts.updateRuntimeSessionProvider.run('claude', claudeModel, claudeModel, now, runtimeSession.id);
4718
4766
  }
4719
- conversationPreferences = persistConversationModelPreference(
4720
- convId,
4721
- requestedRelayMode,
4722
- requestedModel,
4723
- requestedReasoningEffort,
4724
- now,
4725
- );
4767
+ // Another relay's agent picks the model and mode of its own turn; the
4768
+ // composer keeps restoring the user's choice for this conversation.
4769
+ conversationPreferences = remoteRequest.remote && !shouldCreateConversation
4770
+ ? readConversationModelPreference(convId, requestedRelayMode)
4771
+ : persistConversationModelPreference(
4772
+ convId,
4773
+ requestedRelayMode,
4774
+ requestedModel,
4775
+ requestedReasoningEffort,
4776
+ now,
4777
+ );
4726
4778
  stmts.insertQ.run(
4727
4779
  msgId,
4728
4780
  convId,
@@ -4781,7 +4833,12 @@ export function registerMessagesRoutes(app, deps) {
4781
4833
  }
4782
4834
  throw error;
4783
4835
  }
4784
- if (!resendOriginId) {
4836
+ recordRemoteRelayUnlocks(remoteRelayInbound, {
4837
+ conversationId: convId,
4838
+ messageId: msgId,
4839
+ relays: mentionedRemoteRelays,
4840
+ });
4841
+ if (!keepsComposerDraft) {
4785
4842
  io.emit('conversation_draft_updated', {
4786
4843
  conversationId: convId,
4787
4844
  draftText: '',
@@ -4851,7 +4908,7 @@ export function registerMessagesRoutes(app, deps) {
4851
4908
  stmts.clearConvSeed.run(now, convId);
4852
4909
  }
4853
4910
 
4854
- console.log(`[${ts()}] QUEUED ${msgId.slice(0,8)} conv=${convId.slice(0,8)} rs=${String(runtimeSession?.id || 'none').slice(0,8)} owner=${String(ownerSessionId || 'none').slice(0,8)} new=${!conversationId || !!newConversation} model=${requestedModel} variant=${requestedModelVariantId}${requestedReasoningEffort ? ` effort=${requestedReasoningEffort}` : ''} mode=${requestedRelayMode} text="${trimmedText.slice(0,60)}"${shouldApplySeed ? ' seeded=1' : ''}${attachments.length ? ` attachments=${attachments.length}` : ''}`);
4911
+ console.log(`[${ts()}] QUEUED ${msgId.slice(0,8)} conv=${convId.slice(0,8)} rs=${String(runtimeSession?.id || 'none').slice(0,8)} owner=${String(ownerSessionId || 'none').slice(0,8)} new=${!conversationId || !!newConversation} model=${requestedModel} variant=${requestedModelVariantId}${requestedReasoningEffort ? ` effort=${requestedReasoningEffort}` : ''} mode=${requestedRelayMode} text="${trimmedText.slice(0,60)}"${shouldApplySeed ? ' seeded=1' : ''}${attachments.length ? ` attachments=${attachments.length}` : ''}${remoteRequest.remote ? ` remote=${remoteRequest.origin?.relayName || remoteRequest.origin?.relayId || 'unknown'} hops=${remoteRequest.origin?.hops ?? '?'}` : ''}${mentionedRemoteRelays.length ? ` mentions=${mentionedRemoteRelays.length}` : ''}`);
4855
4912
 
4856
4913
  emitToClientsExceptSessionId(
4857
4914
  'user_message',
@@ -4867,6 +4924,7 @@ export function registerMessagesRoutes(app, deps) {
4867
4924
  mode: requestedRelayMode,
4868
4925
  timestamp: now,
4869
4926
  attachments,
4927
+ ...(remoteRequest.origin ? { origin: remoteRequest.origin } : {}),
4870
4928
  },
4871
4929
  },
4872
4930
  sessionId,
@@ -6047,6 +6105,20 @@ export function registerMessagesRoutes(app, deps) {
6047
6105
  return res.json({ ok: true, ...result });
6048
6106
  });
6049
6107
 
6108
+ // Withdraw a queued (not yet started) shutdown/restart. Same trust boundary
6109
+ // as queueing one.
6110
+ app.post('/api/relay/shutdown/cancel', auth, (req, res) => {
6111
+ if (!isLoopbackRequest(req)) {
6112
+ return res.status(403).json({ error: 'Relay shutdown endpoint is localhost-only' });
6113
+ }
6114
+ if (typeof cancelRelayShutdown !== 'function') {
6115
+ return res.status(501).json({ error: 'Relay shutdown orchestration is unavailable' });
6116
+ }
6117
+ const requestedBy = String(req.body?.requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api';
6118
+ const result = cancelRelayShutdown({ requestedBy });
6119
+ return res.json({ ok: true, ...result });
6120
+ });
6121
+
6050
6122
  app.post('/api/image-operations/:operationId/execute', auth, async (req, res) => {
6051
6123
  if (!isLoopbackRequest(req)) {
6052
6124
  return res.status(403).json({ error: 'Image operation execution is localhost-only' });
@@ -6416,7 +6488,11 @@ export function registerMessagesRoutes(app, deps) {
6416
6488
  });
6417
6489
  });
6418
6490
 
6419
- const SETTLED_STEER_KINDS = new Set(['folded', 'stopped']);
6491
+ // Responses that are settle markers, not replies: stamped with their kind,
6492
+ // and no "turn completed" push. The last one is not a steer's: it closes a
6493
+ // message whose answer was published on a continuation row, and that reply
6494
+ // already sent the push.
6495
+ const SETTLED_STEER_KINDS = new Set(['folded', 'stopped', ANSWERED_ELSEWHERE_KIND]);
6420
6496
 
6421
6497
  // POST /api/response — CLI submits response
6422
6498
  app.post('/api/response', auth, async (req, res) => {
@@ -6941,11 +7017,14 @@ export function registerMessagesRoutes(app, deps) {
6941
7017
  io.emit('message_status', { messageId, conversationId: targetConversationId, status: 'done' });
6942
7018
  // An absorbed row is not a finished turn — the reply continues on the
6943
7019
  // steered message's row, whose own completion sends the one notification.
6944
- // A stopped steer has no reply at all.
7020
+ // A stopped steer has no reply at all, and a message answered on a
7021
+ // continuation row was notified by that row.
6945
7022
  if (!settledKind) {
6946
7023
  void pushDispatchService?.notifyTurnComplete?.({
6947
7024
  conversationId: targetConversationId,
6948
7025
  messageId: responseId,
7026
+ // The push service skips turns another relay's agent started.
7027
+ sourceMessageId: messageId,
6949
7028
  text: resolvedText,
6950
7029
  });
6951
7030
  }
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ // Remote relays API (plan §5.1, §5.4, §5.6): this relay's identity, the list
4
+ // of paired relays, adding/pairing, self settings and the single endpoint all
5
+ // agent tool calls converge on. Everything is behind `auth` — including the
6
+ // inbound pairing call, which another relay makes with a token that works here.
7
+ //
8
+ // No response carries a stored token: the registry's public views drop it, and
9
+ // the pairing service's answers never contain one.
10
+
11
+ function sendResult(res, result, fallbackStatus = 400) {
12
+ const { status, ...payload } = result || {};
13
+ const code = Number.isInteger(status) ? status : (payload.ok === false ? fallbackStatus : 200);
14
+ return res.status(code).json(payload);
15
+ }
16
+
17
+ function internalError(res, error, what) {
18
+ return res.status(500).json({ error: `${what} failed: ${String(error?.message || error)}` });
19
+ }
20
+
21
+ export function registerRemoteRelayRoutes(app, deps) {
22
+ const {
23
+ auth,
24
+ registry,
25
+ pairing,
26
+ // WP2's dispatcher: `{ dispatch({ conversationId, action, args, req }) →
27
+ // Promise<{ status, body }>, inflight(conversationId) → number }`.
28
+ dispatcher = null,
29
+ } = deps;
30
+
31
+ // Static paths first: Express would otherwise hand them to /:id.
32
+
33
+ // GET /api/relay/identity — who this relay is, for other relays' probes.
34
+ app.get('/api/relay/identity', auth, (_req, res) => {
35
+ res.json(registry.selfIdentity());
36
+ });
37
+
38
+ app.get('/api/remote-relays', auth, (_req, res) => {
39
+ res.json({
40
+ relays: registry.listPublic(),
41
+ self: { ...registry.selfIdentity(), ...registry.getSelfSettings() },
42
+ });
43
+ });
44
+
45
+ // Workers ask at start whether to register the remote_relay tool at all.
46
+ app.get('/api/remote-relays/summary', auth, (_req, res) => {
47
+ const relays = registry.listPublic();
48
+ res.json({
49
+ count: relays.length,
50
+ online: relays.filter((relay) => relay.lastStatus === 'online').length,
51
+ });
52
+ });
53
+
54
+ // The Grok inactivity hold asks whether a tool call is still running.
55
+ app.get('/api/remote-relays/inflight', auth, (req, res) => {
56
+ const conversationId = String(req.query?.conversationId || '').trim();
57
+ let inflight = 0;
58
+ try {
59
+ inflight = Number(dispatcher?.inflight?.(conversationId)) || 0;
60
+ } catch {}
61
+ res.json({ inflight });
62
+ });
63
+
64
+ // POST /api/remote-relays — add a relay from a pasted link (and pair back).
65
+ app.post('/api/remote-relays', auth, async (req, res) => {
66
+ try {
67
+ sendResult(res, await pairing.addFromLink(req.body || {}));
68
+ } catch (error) {
69
+ internalError(res, error, 'Adding the remote relay');
70
+ }
71
+ });
72
+
73
+ // POST /api/remote-relays/pair — another relay introduces itself.
74
+ app.post('/api/remote-relays/pair', auth, async (req, res) => {
75
+ try {
76
+ sendResult(res, await pairing.acceptPairing(req.body || {}));
77
+ } catch (error) {
78
+ internalError(res, error, 'Pairing');
79
+ }
80
+ });
81
+
82
+ // POST /api/remote-relays/tool — every provider adapter's remote_relay call.
83
+ app.post('/api/remote-relays/tool', auth, async (req, res) => {
84
+ if (!dispatcher) return res.status(503).json({ error: 'Remote relay dispatcher unavailable' });
85
+ // A worker that gives up on the call (turn stopped, worker gone) closes
86
+ // the request: withdraw its approval card and stop waiting for the reply.
87
+ const controller = new AbortController();
88
+ res.on('close', () => {
89
+ if (!res.writableFinished) controller.abort();
90
+ });
91
+ try {
92
+ const result = await dispatcher.dispatch({
93
+ conversationId: req.body?.conversationId,
94
+ action: req.body?.action,
95
+ args: req.body?.args || {},
96
+ req,
97
+ signal: controller.signal,
98
+ });
99
+ if (controller.signal.aborted) return;
100
+ res.status(Number.isInteger(result?.status) ? result.status : 200).json(result?.body ?? {});
101
+ } catch (error) {
102
+ internalError(res, error, 'The remote relay call');
103
+ }
104
+ });
105
+
106
+ app.get('/api/settings/remote-relays', auth, (_req, res) => {
107
+ res.json(registry.getSelfSettings());
108
+ });
109
+
110
+ app.post('/api/settings/remote-relays', auth, (req, res) => {
111
+ sendResult(res, registry.setSelfSettings(req.body || {}));
112
+ });
113
+
114
+ // PATCH /api/remote-relays/:id — { permission, url, token, tokenMode }.
115
+ app.patch('/api/remote-relays/:id', auth, (req, res) => {
116
+ sendResult(res, registry.update(req.params?.id, req.body || {}));
117
+ });
118
+
119
+ // DELETE /api/remote-relays/:id — local only; the other side keeps its entry.
120
+ app.delete('/api/remote-relays/:id', auth, (req, res) => {
121
+ sendResult(res, registry.remove(req.params?.id), 404);
122
+ });
123
+
124
+ app.post('/api/remote-relays/:id/check', auth, async (req, res) => {
125
+ try {
126
+ const relay = await registry.check(req.params?.id);
127
+ if (!relay) return res.status(404).json({ error: 'Unknown remote relay' });
128
+ res.json({ ok: true, relay });
129
+ } catch (error) {
130
+ internalError(res, error, 'The check');
131
+ }
132
+ });
133
+ }
@@ -6,7 +6,6 @@ import fs from 'fs';
6
6
  import path from 'path';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { randomUUID } from 'crypto';
9
- import { spawn } from 'child_process';
10
9
  import { createSdkSessionSyncService } from '../services/sdk-session-sync-service.mjs';
11
10
  import { applySafeServedContentHeaders } from '../services/safe-served-content.mjs';
12
11
  import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
@@ -36,6 +35,8 @@ import {
36
35
  import { workspaceRootDisplayName } from '../workspace-root.mjs';
37
36
  import { createKeyedMutex } from '../services/keyed-mutex-service.mjs';
38
37
  import { createFixedWindowRateLimiter } from '../services/rate-limit-service.mjs';
38
+ import { parseRemoteRelayOriginJson } from '../repositories/remote-relay-repository.mjs';
39
+ import { admitRemoteRelayRequest, publicRemoteRelayOrigin } from '../services/remote-relay-inbound.mjs';
39
40
  import {
40
41
  RELAUNCH_COALESCE_WINDOW_MS,
41
42
  buildRelaunchRequestKey,
@@ -686,26 +687,6 @@ function isOpenAIImageModelId(model = '') {
686
687
  return normalized.startsWith('gpt-image-') || normalized.startsWith('dall-e-');
687
688
  }
688
689
 
689
- function runHostSuspendToRam() {
690
- if (process.platform !== 'win32') {
691
- return { ok: false, statusCode: 501, error: 'Host suspend is only supported on Windows' };
692
- }
693
- try {
694
- const child = spawn('rundll32.exe', ['powrprof.dll,SetSuspendState', '0,0,0'], {
695
- detached: true,
696
- stdio: 'ignore',
697
- windowsHide: true,
698
- });
699
- child.unref?.();
700
- return {
701
- ok: true,
702
- command: 'rundll32.exe powrprof.dll,SetSuspendState 0,0,0',
703
- };
704
- } catch (error) {
705
- return { ok: false, statusCode: 500, error: error?.message || 'Failed to launch host suspend command' };
706
- }
707
- }
708
-
709
690
  function isPidAlive(pidValue) {
710
691
  const pid = Number(pidValue);
711
692
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -1766,6 +1747,8 @@ export function buildConversationMessages({
1766
1747
  const queueRow = sourceMessageId
1767
1748
  ? queueRowsById.get(sourceMessageId)
1768
1749
  : (message?.role === 'user' ? queueRowsById.get(id) : null);
1750
+ // Provenance of a prompt another relay's agent sent: the bubble's badge.
1751
+ const origin = message?.role === 'user' ? parseRemoteRelayOriginJson(message?.origin_json) : null;
1769
1752
  return {
1770
1753
  activities: message?.role === 'assistant' ? (relayActivitiesByMessageId.get(id) || []) : [],
1771
1754
  thoughts: message?.role === 'assistant' ? (relayThoughtsByMessageId.get(id) || []) : [],
@@ -1789,13 +1772,16 @@ export function buildConversationMessages({
1789
1772
  executedProvider: message?.executed_provider || message?.executedProvider || undefined,
1790
1773
  // 'continuation' badges a self-started turn; 'absorbed' marks a
1791
1774
  // reply that continues through the next (steered) user message;
1792
- // 'folded'/'stopped' are a steer's settle markers. Without this the
1793
- // live-appended badge/merge/marker vanished on reload.
1775
+ // 'folded'/'stopped' are a steer's settle markers, and
1776
+ // 'answered-elsewhere' marks a message whose answer is on a
1777
+ // continuation row. Without this the live-appended
1778
+ // badge/merge/marker vanished on reload.
1794
1779
  kind: message?.kind || undefined,
1795
1780
  resendOfMessageId: message?.role === 'user' ? (message?.resend_of_message_id || undefined) : undefined,
1796
1781
  resentAs: (message?.role === 'assistant' && message?.kind === 'stopped' && sourceMessageId)
1797
1782
  ? (liveResends.get(sourceMessageId) || undefined)
1798
1783
  : undefined,
1784
+ ...(origin ? { origin } : {}),
1799
1785
  };
1800
1786
  })
1801
1787
  : [];
@@ -2105,6 +2091,13 @@ export function registerSessionsRoutes(app, deps) {
2105
2091
  windowsBootAutostartService,
2106
2092
  isSha256,
2107
2093
  uploadPathForSha,
2094
+ hostSuspendService = null,
2095
+ collectHostActivity = null,
2096
+ hostSuspendPlatform = process.platform,
2097
+ // Remote relays (server/services/remote-relay-inbound.mjs): origin on
2098
+ // conversations another relay's agent created, and the inbound switch.
2099
+ // Absent in older wiring and most tests, where both are no-ops.
2100
+ remoteRelayInbound = null,
2108
2101
  } = deps;
2109
2102
  const sdkSessionSyncService = createSdkSessionSyncService(db);
2110
2103
  // Generic synchronous transaction runner for the session-sync route: binding
@@ -2633,7 +2626,11 @@ export function registerSessionsRoutes(app, deps) {
2633
2626
  });
2634
2627
  messages = messages.map((message) => {
2635
2628
  const sourceMessageId = responseMessageToSourceId.get(String(message.id || '').trim()) || message.sourceMessageId || undefined;
2636
- const nextMessage = sourceMessageId ? { ...message, sourceMessageId } : message;
2629
+ const withSource = sourceMessageId ? { ...message, sourceMessageId } : message;
2630
+ // A public share never shows another relay's address or ids.
2631
+ const nextMessage = withSource.origin
2632
+ ? { ...withSource, origin: publicRemoteRelayOrigin(withSource.origin) }
2633
+ : withSource;
2637
2634
  const attachments = Array.isArray(nextMessage?.attachments)
2638
2635
  ? nextMessage.attachments.map((attachment) => rewriteSharedGeneratedImageAttachmentContentUrl(rewriteSharedAttachmentContentUrl(attachment, shareToken), shareToken))
2639
2636
  : nextMessage?.attachments;
@@ -2782,6 +2779,8 @@ export function registerSessionsRoutes(app, deps) {
2782
2779
  draftAttachments: parseDraftAttachmentsColumn(r.draft_attachments),
2783
2780
  draftUpdatedAt: r.draft_updated_at || null,
2784
2781
  draftUpdatedByClientId: r.draft_updated_by_client_id || null,
2782
+ // Set on conversations another relay's agent created ("via <relay>").
2783
+ origin: parseRemoteRelayOriginJson(r.origin_json),
2785
2784
  };
2786
2785
  });
2787
2786
 
@@ -3423,6 +3422,7 @@ export function registerSessionsRoutes(app, deps) {
3423
3422
  draftAttachments: parseDraftAttachmentsColumn(conv.draft_attachments),
3424
3423
  draftUpdatedAt: conv.draft_updated_at || null,
3425
3424
  draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
3425
+ origin: parseRemoteRelayOriginJson(conv.origin_json),
3426
3426
  messages: history.messages,
3427
3427
  pageInfo: history.pageInfo,
3428
3428
  refreshed: true,
@@ -3818,6 +3818,10 @@ export function registerSessionsRoutes(app, deps) {
3818
3818
  updatedAt: conv.updated_at,
3819
3819
  sessionUsageSummary,
3820
3820
  inFlight,
3821
+ // A queued (pending/parked) row counts too: inFlight covers only the
3822
+ // processing one. Another relay's agent tells "still queued" from
3823
+ // "cancelled" with it.
3824
+ activeTurn: Number(stmts.getConversationActiveQueueCount?.get?.(resolvedConversationId)?.count || 0) > 0,
3821
3825
  backgroundTasks: backgroundTaskStore?.get?.(resolvedConversationId) || [],
3822
3826
  previews: listConversationPreviews?.(resolvedConversationId) || [],
3823
3827
  preferredRelayMode: preferences.preferredRelayMode,
@@ -3827,6 +3831,8 @@ export function registerSessionsRoutes(app, deps) {
3827
3831
  draftAttachments: parseDraftAttachmentsColumn(conv.draft_attachments),
3828
3832
  draftUpdatedAt: conv.draft_updated_at || null,
3829
3833
  draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
3834
+ // Set on conversations another relay's agent created ("via <relay>").
3835
+ origin: parseRemoteRelayOriginJson(conv.origin_json),
3830
3836
  messages: history.messages,
3831
3837
  pageInfo: history.pageInfo,
3832
3838
  });
@@ -4548,6 +4554,7 @@ export function registerSessionsRoutes(app, deps) {
4548
4554
  });
4549
4555
  const orphanedUploads = collectOrphanedUploadsFromConversation(id);
4550
4556
  hardDeleteConversationRows(id);
4557
+ remoteRelayInbound?.repository?.forgetConversation?.(id);
4551
4558
  deleteOrphanedUploads(orphanedUploads);
4552
4559
  if (sdkSessionId && !cliSession.queued) stmts.deleteSdkDeleteRequest?.run?.(sdkSessionId);
4553
4560
  io.emit('conversation_deleted', { conversationId: id });
@@ -4556,6 +4563,7 @@ export function registerSessionsRoutes(app, deps) {
4556
4563
 
4557
4564
  // POST /api/conversation/:id/archive — archive conversation
4558
4565
  app.post('/api/conversation/:id/archive', auth, async (req, res) => {
4566
+ if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
4559
4567
  const id = String(req.params.id || '').trim();
4560
4568
  if (!id) return res.status(400).json({ error: 'Missing conversation id' });
4561
4569
  try {
@@ -4573,6 +4581,10 @@ export function registerSessionsRoutes(app, deps) {
4573
4581
  });
4574
4582
 
4575
4583
  app.post('/api/conversation/bootstrap', auth, async (req, res) => {
4584
+ // Another relay's agent starting a session: refused while the inbound
4585
+ // switch is off, otherwise the conversation keeps its provenance.
4586
+ const remoteRequest = admitRemoteRelayRequest(req, res, remoteRelayInbound);
4587
+ if (!remoteRequest) return;
4576
4588
  const clientSessionId = String(ensureSessionId(req, res) || '').trim();
4577
4589
  const now = new Date().toISOString();
4578
4590
  let conversationId = '';
@@ -4868,6 +4880,7 @@ export function registerSessionsRoutes(app, deps) {
4868
4880
  // single canonical tuple.
4869
4881
  const commitBootstrapState = db.transaction(() => {
4870
4882
  stmts.insertConv.run(conversationId, title, now, now);
4883
+ if (remoteRequest.origin) remoteRelayInbound?.repository?.setConversationOrigin?.(conversationId, remoteRequest.origin);
4871
4884
  const boundRuntimeSession = ensureRuntimeSessionBinding(
4872
4885
  conversationId,
4873
4886
  selectedModel,
@@ -4987,6 +5000,7 @@ export function registerSessionsRoutes(app, deps) {
4987
5000
  ok: true,
4988
5001
  conversationId,
4989
5002
  conversation,
5003
+ origin: parseRemoteRelayOriginJson(conversation?.origin_json),
4990
5004
  runtimeSessionId: runtimeSession?.id || null,
4991
5005
  ownerSessionId,
4992
5006
  worker: ownerWorker,
@@ -5088,6 +5102,7 @@ export function registerSessionsRoutes(app, deps) {
5088
5102
  activeBridgeOwner: runtimeState.activeBridgeOwner || null,
5089
5103
  restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
5090
5104
  relayShutdown: runtimeState.relayShutdown || null,
5105
+ hostSuspend: runtimeState.hostSuspend || null,
5091
5106
  platform: process.platform,
5092
5107
  features: featureFlags || {},
5093
5108
  sessionWorker: sessionWorkerStatus,
@@ -6182,21 +6197,59 @@ export function registerSessionsRoutes(app, deps) {
6182
6197
  });
6183
6198
  });
6184
6199
 
6200
+ // Deferred host suspend: the request is queued and fulfilled by the
6201
+ // host-suspend service once nothing runs any more (see
6202
+ // docs/plans/2026-09-27-deferred-host-suspend.md).
6203
+ app.get('/api/host/suspend', auth, (req, res) => {
6204
+ if (!hostSuspendService) {
6205
+ return res.status(501).json({ ok: false, error: 'Host suspend is unavailable' });
6206
+ }
6207
+ const state = hostSuspendService.refresh();
6208
+ let activity = null;
6209
+ if (!state.pending && typeof collectHostActivity === 'function') {
6210
+ try {
6211
+ activity = collectHostActivity({ request: null });
6212
+ } catch (error) {
6213
+ activity = { blockers: [], error: String(error?.message || error) };
6214
+ }
6215
+ }
6216
+ return res.json({
6217
+ ok: true,
6218
+ supported: hostSuspendPlatform === 'win32',
6219
+ state,
6220
+ blockers: state.pending ? state.blockers : (activity?.blockers || []),
6221
+ activity,
6222
+ });
6223
+ });
6224
+
6185
6225
  app.post('/api/host/suspend', auth, (req, res) => {
6186
- const result = runHostSuspendToRam();
6187
- if (!result?.ok) {
6188
- return res.status(result?.statusCode || 500).json({
6189
- ok: false,
6190
- error: result?.error || 'Failed to suspend host',
6191
- });
6226
+ if (!hostSuspendService) {
6227
+ return res.status(501).json({ ok: false, error: 'Host suspend is unavailable' });
6228
+ }
6229
+ if (hostSuspendPlatform !== 'win32') {
6230
+ return res.status(501).json({ ok: false, error: 'Host suspend is only supported on Windows' });
6192
6231
  }
6232
+ const reason = String(req.body?.reason || 'manual-suspend').trim().slice(0, 140) || 'manual-suspend';
6233
+ const requestedBy = String(req.body?.requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api';
6234
+ const result = hostSuspendService.request({ reason, requestedBy });
6193
6235
  return res.status(202).json({
6194
6236
  ok: true,
6195
6237
  queued: true,
6196
- command: result.command,
6238
+ accepted: result.accepted !== false,
6239
+ alreadyPending: !!result.alreadyPending,
6240
+ state: result.state,
6197
6241
  });
6198
6242
  });
6199
6243
 
6244
+ app.post('/api/host/suspend/cancel', auth, (req, res) => {
6245
+ if (!hostSuspendService) {
6246
+ return res.status(501).json({ ok: false, error: 'Host suspend is unavailable' });
6247
+ }
6248
+ const requestedBy = String(req.body?.requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api';
6249
+ const result = hostSuspendService.cancel({ requestedBy });
6250
+ return res.json({ ok: true, cancelled: !!result.cancelled, state: result.state });
6251
+ });
6252
+
6200
6253
  app.get('/api/restart-orchestrator', auth, (req, res) => {
6201
6254
  ensureSessionId(req, res);
6202
6255
  return res.json({ orchestrator: relayRestartOrchestrator?.getState?.() || null });