@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.
- package/.github/extensions/web-relay/extension.mjs +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +67 -0
- package/README.md +62 -13
- package/package.json +5 -2
- package/server/README.md +43 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +92 -25
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
- package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
- package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
- package/server/db-schema.mjs +8 -0
- package/server/grok-worker/acp-client.mjs +92 -6
- package/server/grok-worker/grok-turn-runner.mjs +59 -8
- package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
- package/server/mcp/oar-mcp-server.mjs +473 -0
- package/server/migrations/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +103 -0
- package/server/public/app/bootstrap.js +21 -6
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-view.js +98 -7
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +4 -1
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
- package/server/public/app/remote-relay-origin-view.mjs +76 -0
- package/server/public/app/remote-relay-shared.mjs +85 -0
- package/server/public/app/remote-relays-settings.js +618 -0
- package/server/public/app/remote-relays-store.mjs +126 -0
- package/server/public/app/settings-modal.js +4 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +18 -1
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +4 -0
- package/server/public/index.html +257 -8
- package/server/relay-tools.md +5 -1
- package/server/repositories/message-repository.mjs +7 -0
- package/server/repositories/question-repository.mjs +3 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +6 -1
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +90 -16
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +80 -29
- package/server/server-runtime.mjs +238 -0
- package/server/services/github-ci-activity-service.mjs +199 -0
- package/server/services/host-suspend-command.mjs +38 -0
- package/server/services/host-suspend-service.mjs +254 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/remote-relay-caller-context.mjs +341 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1500 -0
- package/server/services/remote-relay-inbound.mjs +187 -0
- package/server/services/remote-relay-pairing.mjs +189 -0
- package/server/services/remote-relay-registry-service.mjs +581 -0
- package/server/services/session-worker-launch-service.mjs +131 -12
- package/shared/remote-relay-contract.mjs +539 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
6187
|
-
|
|
6188
|
-
|
|
6189
|
-
|
|
6190
|
-
|
|
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
|
-
|
|
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 });
|