@oar-sh/oar 0.9.3 → 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 (68) 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 +67 -0
  4. package/README.md +62 -13
  5. package/package.json +5 -2
  6. package/server/README.md +43 -3
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  9. package/server/claude-worker/claude-session-process.mjs +92 -25
  10. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  11. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  12. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
  13. package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
  14. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  15. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  16. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  17. package/server/db-schema.mjs +8 -0
  18. package/server/grok-worker/acp-client.mjs +92 -6
  19. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  20. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  21. package/server/mcp/oar-mcp-server.mjs +473 -0
  22. package/server/migrations/0005-remote-relays.mjs +90 -0
  23. package/server/public/app/action-confirmations.js +141 -13
  24. package/server/public/app/api-client.js +103 -0
  25. package/server/public/app/bootstrap.js +21 -6
  26. package/server/public/app/conversation-deep-link.mjs +42 -0
  27. package/server/public/app/conversation-view.js +98 -7
  28. package/server/public/app/host-suspend-ui.js +272 -0
  29. package/server/public/app/journal-view.js +4 -1
  30. package/server/public/app/mention-autocomplete.mjs +298 -0
  31. package/server/public/app/push-settings.js +1 -0
  32. package/server/public/app/pwa-install.js +8 -3
  33. package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
  34. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  35. package/server/public/app/remote-relay-shared.mjs +85 -0
  36. package/server/public/app/remote-relays-settings.js +618 -0
  37. package/server/public/app/remote-relays-store.mjs +126 -0
  38. package/server/public/app/settings-modal.js +4 -2
  39. package/server/public/app/settings-tabs.js +1 -1
  40. package/server/public/app/socket-handlers.js +18 -1
  41. package/server/public/app/thinking-follow.js +260 -0
  42. package/server/public/app/tool-activity-emoji.mjs +4 -0
  43. package/server/public/index.html +257 -8
  44. package/server/relay-tools.md +5 -1
  45. package/server/repositories/message-repository.mjs +7 -0
  46. package/server/repositories/question-repository.mjs +3 -0
  47. package/server/repositories/remote-relay-repository.mjs +75 -0
  48. package/server/repositories/session-repository.mjs +6 -1
  49. package/server/routes/ask-user-routes.mjs +42 -2
  50. package/server/routes/messages-routes.mjs +90 -16
  51. package/server/routes/remote-relay-routes.mjs +133 -0
  52. package/server/routes/sessions-routes.mjs +80 -29
  53. package/server/server-runtime.mjs +238 -0
  54. package/server/services/github-ci-activity-service.mjs +199 -0
  55. package/server/services/host-suspend-command.mjs +38 -0
  56. package/server/services/host-suspend-service.mjs +254 -0
  57. package/server/services/push-dispatch-service.mjs +37 -4
  58. package/server/services/remote-relay-caller-context.mjs +341 -0
  59. package/server/services/remote-relay-client.mjs +332 -0
  60. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  61. package/server/services/remote-relay-inbound.mjs +187 -0
  62. package/server/services/remote-relay-pairing.mjs +189 -0
  63. package/server/services/remote-relay-registry-service.mjs +581 -0
  64. package/server/services/session-worker-launch-service.mjs +131 -12
  65. package/shared/remote-relay-contract.mjs +539 -0
  66. package/shared/remote-relay-mentions.mjs +129 -0
  67. package/shared/remote-relay-tool-core.mjs +314 -0
  68. package/shared/worker-runtime/api-client.mjs +112 -22
@@ -40,6 +40,14 @@ import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
40
40
  import { 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' });
@@ -6946,6 +7018,8 @@ export function registerMessagesRoutes(app, deps) {
6946
7018
  void pushDispatchService?.notifyTurnComplete?.({
6947
7019
  conversationId: targetConversationId,
6948
7020
  messageId: responseId,
7021
+ // The push service skips turns another relay's agent started.
7022
+ sourceMessageId: messageId,
6949
7023
  text: resolvedText,
6950
7024
  });
6951
7025
  }
@@ -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) || []) : [],
@@ -1796,6 +1779,7 @@ export function buildConversationMessages({
1796
1779
  resentAs: (message?.role === 'assistant' && message?.kind === 'stopped' && sourceMessageId)
1797
1780
  ? (liveResends.get(sourceMessageId) || undefined)
1798
1781
  : undefined,
1782
+ ...(origin ? { origin } : {}),
1799
1783
  };
1800
1784
  })
1801
1785
  : [];
@@ -2105,6 +2089,13 @@ export function registerSessionsRoutes(app, deps) {
2105
2089
  windowsBootAutostartService,
2106
2090
  isSha256,
2107
2091
  uploadPathForSha,
2092
+ hostSuspendService = null,
2093
+ collectHostActivity = null,
2094
+ hostSuspendPlatform = process.platform,
2095
+ // Remote relays (server/services/remote-relay-inbound.mjs): origin on
2096
+ // conversations another relay's agent created, and the inbound switch.
2097
+ // Absent in older wiring and most tests, where both are no-ops.
2098
+ remoteRelayInbound = null,
2108
2099
  } = deps;
2109
2100
  const sdkSessionSyncService = createSdkSessionSyncService(db);
2110
2101
  // Generic synchronous transaction runner for the session-sync route: binding
@@ -2633,7 +2624,11 @@ export function registerSessionsRoutes(app, deps) {
2633
2624
  });
2634
2625
  messages = messages.map((message) => {
2635
2626
  const sourceMessageId = responseMessageToSourceId.get(String(message.id || '').trim()) || message.sourceMessageId || undefined;
2636
- const nextMessage = sourceMessageId ? { ...message, sourceMessageId } : message;
2627
+ const withSource = sourceMessageId ? { ...message, sourceMessageId } : message;
2628
+ // A public share never shows another relay's address or ids.
2629
+ const nextMessage = withSource.origin
2630
+ ? { ...withSource, origin: publicRemoteRelayOrigin(withSource.origin) }
2631
+ : withSource;
2637
2632
  const attachments = Array.isArray(nextMessage?.attachments)
2638
2633
  ? nextMessage.attachments.map((attachment) => rewriteSharedGeneratedImageAttachmentContentUrl(rewriteSharedAttachmentContentUrl(attachment, shareToken), shareToken))
2639
2634
  : nextMessage?.attachments;
@@ -2782,6 +2777,8 @@ export function registerSessionsRoutes(app, deps) {
2782
2777
  draftAttachments: parseDraftAttachmentsColumn(r.draft_attachments),
2783
2778
  draftUpdatedAt: r.draft_updated_at || null,
2784
2779
  draftUpdatedByClientId: r.draft_updated_by_client_id || null,
2780
+ // Set on conversations another relay's agent created ("via <relay>").
2781
+ origin: parseRemoteRelayOriginJson(r.origin_json),
2785
2782
  };
2786
2783
  });
2787
2784
 
@@ -3423,6 +3420,7 @@ export function registerSessionsRoutes(app, deps) {
3423
3420
  draftAttachments: parseDraftAttachmentsColumn(conv.draft_attachments),
3424
3421
  draftUpdatedAt: conv.draft_updated_at || null,
3425
3422
  draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
3423
+ origin: parseRemoteRelayOriginJson(conv.origin_json),
3426
3424
  messages: history.messages,
3427
3425
  pageInfo: history.pageInfo,
3428
3426
  refreshed: true,
@@ -3818,6 +3816,10 @@ export function registerSessionsRoutes(app, deps) {
3818
3816
  updatedAt: conv.updated_at,
3819
3817
  sessionUsageSummary,
3820
3818
  inFlight,
3819
+ // A queued (pending/parked) row counts too: inFlight covers only the
3820
+ // processing one. Another relay's agent tells "still queued" from
3821
+ // "cancelled" with it.
3822
+ activeTurn: Number(stmts.getConversationActiveQueueCount?.get?.(resolvedConversationId)?.count || 0) > 0,
3821
3823
  backgroundTasks: backgroundTaskStore?.get?.(resolvedConversationId) || [],
3822
3824
  previews: listConversationPreviews?.(resolvedConversationId) || [],
3823
3825
  preferredRelayMode: preferences.preferredRelayMode,
@@ -3827,6 +3829,8 @@ export function registerSessionsRoutes(app, deps) {
3827
3829
  draftAttachments: parseDraftAttachmentsColumn(conv.draft_attachments),
3828
3830
  draftUpdatedAt: conv.draft_updated_at || null,
3829
3831
  draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
3832
+ // Set on conversations another relay's agent created ("via <relay>").
3833
+ origin: parseRemoteRelayOriginJson(conv.origin_json),
3830
3834
  messages: history.messages,
3831
3835
  pageInfo: history.pageInfo,
3832
3836
  });
@@ -4548,6 +4552,7 @@ export function registerSessionsRoutes(app, deps) {
4548
4552
  });
4549
4553
  const orphanedUploads = collectOrphanedUploadsFromConversation(id);
4550
4554
  hardDeleteConversationRows(id);
4555
+ remoteRelayInbound?.repository?.forgetConversation?.(id);
4551
4556
  deleteOrphanedUploads(orphanedUploads);
4552
4557
  if (sdkSessionId && !cliSession.queued) stmts.deleteSdkDeleteRequest?.run?.(sdkSessionId);
4553
4558
  io.emit('conversation_deleted', { conversationId: id });
@@ -4556,6 +4561,7 @@ export function registerSessionsRoutes(app, deps) {
4556
4561
 
4557
4562
  // POST /api/conversation/:id/archive — archive conversation
4558
4563
  app.post('/api/conversation/:id/archive', auth, async (req, res) => {
4564
+ if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
4559
4565
  const id = String(req.params.id || '').trim();
4560
4566
  if (!id) return res.status(400).json({ error: 'Missing conversation id' });
4561
4567
  try {
@@ -4573,6 +4579,10 @@ export function registerSessionsRoutes(app, deps) {
4573
4579
  });
4574
4580
 
4575
4581
  app.post('/api/conversation/bootstrap', auth, async (req, res) => {
4582
+ // Another relay's agent starting a session: refused while the inbound
4583
+ // switch is off, otherwise the conversation keeps its provenance.
4584
+ const remoteRequest = admitRemoteRelayRequest(req, res, remoteRelayInbound);
4585
+ if (!remoteRequest) return;
4576
4586
  const clientSessionId = String(ensureSessionId(req, res) || '').trim();
4577
4587
  const now = new Date().toISOString();
4578
4588
  let conversationId = '';
@@ -4868,6 +4878,7 @@ export function registerSessionsRoutes(app, deps) {
4868
4878
  // single canonical tuple.
4869
4879
  const commitBootstrapState = db.transaction(() => {
4870
4880
  stmts.insertConv.run(conversationId, title, now, now);
4881
+ if (remoteRequest.origin) remoteRelayInbound?.repository?.setConversationOrigin?.(conversationId, remoteRequest.origin);
4871
4882
  const boundRuntimeSession = ensureRuntimeSessionBinding(
4872
4883
  conversationId,
4873
4884
  selectedModel,
@@ -4987,6 +4998,7 @@ export function registerSessionsRoutes(app, deps) {
4987
4998
  ok: true,
4988
4999
  conversationId,
4989
5000
  conversation,
5001
+ origin: parseRemoteRelayOriginJson(conversation?.origin_json),
4990
5002
  runtimeSessionId: runtimeSession?.id || null,
4991
5003
  ownerSessionId,
4992
5004
  worker: ownerWorker,
@@ -5088,6 +5100,7 @@ export function registerSessionsRoutes(app, deps) {
5088
5100
  activeBridgeOwner: runtimeState.activeBridgeOwner || null,
5089
5101
  restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
5090
5102
  relayShutdown: runtimeState.relayShutdown || null,
5103
+ hostSuspend: runtimeState.hostSuspend || null,
5091
5104
  platform: process.platform,
5092
5105
  features: featureFlags || {},
5093
5106
  sessionWorker: sessionWorkerStatus,
@@ -6182,21 +6195,59 @@ export function registerSessionsRoutes(app, deps) {
6182
6195
  });
6183
6196
  });
6184
6197
 
6198
+ // Deferred host suspend: the request is queued and fulfilled by the
6199
+ // host-suspend service once nothing runs any more (see
6200
+ // docs/plans/2026-09-27-deferred-host-suspend.md).
6201
+ app.get('/api/host/suspend', auth, (req, res) => {
6202
+ if (!hostSuspendService) {
6203
+ return res.status(501).json({ ok: false, error: 'Host suspend is unavailable' });
6204
+ }
6205
+ const state = hostSuspendService.refresh();
6206
+ let activity = null;
6207
+ if (!state.pending && typeof collectHostActivity === 'function') {
6208
+ try {
6209
+ activity = collectHostActivity({ request: null });
6210
+ } catch (error) {
6211
+ activity = { blockers: [], error: String(error?.message || error) };
6212
+ }
6213
+ }
6214
+ return res.json({
6215
+ ok: true,
6216
+ supported: hostSuspendPlatform === 'win32',
6217
+ state,
6218
+ blockers: state.pending ? state.blockers : (activity?.blockers || []),
6219
+ activity,
6220
+ });
6221
+ });
6222
+
6185
6223
  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
- });
6224
+ if (!hostSuspendService) {
6225
+ return res.status(501).json({ ok: false, error: 'Host suspend is unavailable' });
6226
+ }
6227
+ if (hostSuspendPlatform !== 'win32') {
6228
+ return res.status(501).json({ ok: false, error: 'Host suspend is only supported on Windows' });
6192
6229
  }
6230
+ const reason = String(req.body?.reason || 'manual-suspend').trim().slice(0, 140) || 'manual-suspend';
6231
+ const requestedBy = String(req.body?.requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api';
6232
+ const result = hostSuspendService.request({ reason, requestedBy });
6193
6233
  return res.status(202).json({
6194
6234
  ok: true,
6195
6235
  queued: true,
6196
- command: result.command,
6236
+ accepted: result.accepted !== false,
6237
+ alreadyPending: !!result.alreadyPending,
6238
+ state: result.state,
6197
6239
  });
6198
6240
  });
6199
6241
 
6242
+ app.post('/api/host/suspend/cancel', auth, (req, res) => {
6243
+ if (!hostSuspendService) {
6244
+ return res.status(501).json({ ok: false, error: 'Host suspend is unavailable' });
6245
+ }
6246
+ const requestedBy = String(req.body?.requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api';
6247
+ const result = hostSuspendService.cancel({ requestedBy });
6248
+ return res.json({ ok: true, cancelled: !!result.cancelled, state: result.state });
6249
+ });
6250
+
6200
6251
  app.get('/api/restart-orchestrator', auth, (req, res) => {
6201
6252
  ensureSessionId(req, res);
6202
6253
  return res.json({ orchestrator: relayRestartOrchestrator?.getState?.() || null });