@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.
- package/.github/extensions/web-relay/extension.mjs +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +98 -0
- package/README.md +76 -14
- package/package.json +5 -2
- package/server/README.md +43 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +100 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +385 -43
- package/server/claude-worker/claude-session-worker.mjs +5 -0
- 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/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-session-worker.mjs +5 -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-session-worker.mjs +5 -0
- 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 +100 -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/steered-turn-merge.mjs +10 -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 +264 -11
- 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 +98 -19
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +84 -31
- 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 +346 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1743 -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 +148 -14
- package/shared/remote-relay-contract.mjs +556 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- package/shared/steer-settle-failure.mjs +6 -0
- package/shared/steer-settle-markers.mjs +16 -0
- package/shared/worker-runtime/api-client.mjs +112 -22
- 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
|
-
|
|
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' });
|
|
@@ -6416,7 +6488,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6416
6488
|
});
|
|
6417
6489
|
});
|
|
6418
6490
|
|
|
6419
|
-
|
|
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
|
|
1793
|
-
//
|
|
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
|
|
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
|
-
|
|
6187
|
-
|
|
6188
|
-
|
|
6189
|
-
|
|
6190
|
-
|
|
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
|
-
|
|
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 });
|