@oar-sh/oar 0.9.2 → 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 (138) 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 +336 -0
  4. package/README.md +455 -423
  5. package/bin/oar.js +25 -10
  6. package/package.json +11 -5
  7. package/server/README.md +44 -4
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  10. package/server/claude-worker/claude-session-process.mjs +1073 -69
  11. package/server/claude-worker/claude-session-worker.mjs +45 -4
  12. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  13. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  15. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  16. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  17. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  18. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  19. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
  20. package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
  21. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  22. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  23. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  24. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  25. package/server/db-schema.mjs +28 -0
  26. package/server/grok-worker/acp-client.mjs +92 -6
  27. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  28. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  29. package/server/mcp/oar-mcp-server.mjs +473 -0
  30. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  31. package/server/migrations/0005-remote-relays.mjs +90 -0
  32. package/server/public/app/action-confirmations.js +141 -13
  33. package/server/public/app/api-client.js +125 -2
  34. package/server/public/app/ask-user-view.js +102 -13
  35. package/server/public/app/attachments-view.js +37 -2
  36. package/server/public/app/background-task-timeout-options.mjs +9 -0
  37. package/server/public/app/background-tasks-view.mjs +33 -18
  38. package/server/public/app/bootstrap.js +55 -14
  39. package/server/public/app/composer-control-state.mjs +52 -34
  40. package/server/public/app/conversation-deep-link.mjs +42 -0
  41. package/server/public/app/conversation-draft-sync.mjs +187 -0
  42. package/server/public/app/conversation-list-filter.mjs +86 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  44. package/server/public/app/conversation-view.js +1016 -200
  45. package/server/public/app/emoji-view.js +2 -0
  46. package/server/public/app/font-scaling.js +4 -37
  47. package/server/public/app/host-suspend-ui.js +272 -0
  48. package/server/public/app/journal-view.js +149 -6
  49. package/server/public/app/mention-autocomplete.mjs +298 -0
  50. package/server/public/app/model-selector-options.mjs +1 -1
  51. package/server/public/app/push-settings.js +1 -0
  52. package/server/public/app/pwa-install.js +8 -3
  53. package/server/public/app/question-multi-select.mjs +52 -0
  54. package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
  55. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  56. package/server/public/app/remote-relay-shared.mjs +85 -0
  57. package/server/public/app/remote-relays-settings.js +618 -0
  58. package/server/public/app/remote-relays-store.mjs +126 -0
  59. package/server/public/app/settings-modal.js +12 -8
  60. package/server/public/app/settings-tabs.js +1 -1
  61. package/server/public/app/socket-handlers.js +37 -1
  62. package/server/public/app/steered-turn-merge.mjs +100 -0
  63. package/server/public/app/store.js +126 -15
  64. package/server/public/app/sync-outbox.mjs +16 -8
  65. package/server/public/app/thinking-follow.js +260 -0
  66. package/server/public/app/tool-activity-emoji.mjs +40 -0
  67. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  68. package/server/public/index.html +463 -17
  69. package/server/public/sw.js +7 -0
  70. package/server/relay-tools.md +7 -1
  71. package/server/repositories/message-repository.mjs +68 -18
  72. package/server/repositories/question-repository.mjs +18 -0
  73. package/server/repositories/remote-relay-repository.mjs +75 -0
  74. package/server/repositories/session-repository.mjs +13 -32
  75. package/server/routes/ask-user-routes.mjs +42 -2
  76. package/server/routes/messages-routes.mjs +572 -56
  77. package/server/routes/remote-relay-routes.mjs +133 -0
  78. package/server/routes/sessions-routes.mjs +378 -84
  79. package/server/server-runtime.mjs +303 -25
  80. package/server/services/github-ci-activity-service.mjs +199 -0
  81. package/server/services/host-suspend-command.mjs +38 -0
  82. package/server/services/host-suspend-service.mjs +254 -0
  83. package/server/services/live-turn-picker.mjs +47 -0
  84. package/server/services/push-dispatch-service.mjs +37 -4
  85. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  86. package/server/services/relay-question-context.mjs +46 -0
  87. package/server/services/remote-relay-caller-context.mjs +341 -0
  88. package/server/services/remote-relay-client.mjs +332 -0
  89. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  90. package/server/services/remote-relay-inbound.mjs +187 -0
  91. package/server/services/remote-relay-pairing.mjs +189 -0
  92. package/server/services/remote-relay-registry-service.mjs +581 -0
  93. package/server/services/sdk-session-import-service.mjs +49 -7
  94. package/server/services/session-worker-launch-service.mjs +131 -12
  95. package/server/services/session-worker-registry-service.mjs +75 -0
  96. package/server/services/session-worker-supervisor-service.mjs +7 -1
  97. package/server/services/session-worker-websocket-service.mjs +23 -1
  98. package/server/services/steer-resend.mjs +45 -0
  99. package/shared/background-task-timeout.mjs +12 -4
  100. package/shared/control-poller.mjs +4 -1
  101. package/shared/question-multi-select.mjs +16 -0
  102. package/shared/relay-stop-failure.mjs +15 -0
  103. package/shared/remote-relay-contract.mjs +539 -0
  104. package/shared/remote-relay-mentions.mjs +129 -0
  105. package/shared/remote-relay-tool-core.mjs +314 -0
  106. package/shared/slash-command-guard.mjs +76 -0
  107. package/shared/steer-note.mjs +66 -0
  108. package/shared/steer-settle-failure.mjs +89 -0
  109. package/shared/steer-settle-markers.mjs +18 -0
  110. package/shared/worker-crash-guard.mjs +40 -25
  111. package/shared/worker-runtime/api-client.mjs +112 -22
  112. package/shared/worker-runtime/heartbeat.mjs +55 -1
  113. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  114. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  115. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  116. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  117. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  118. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  119. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  120. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  121. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  122. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  123. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  124. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  125. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  126. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  127. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  128. package/server/routes/messages-routes-test-harness.mjs +0 -111
  129. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  130. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  131. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  132. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  133. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  134. package/server/services/fixtures/cli-install-stub.sh +0 -4
  135. package/server/services/fixtures/grok-stub.cmd +0 -1
  136. package/server/services/fixtures/grok-stub.mjs +0 -89
  137. package/server/services/fixtures/grok-stub.sh +0 -4
  138. package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
@@ -1,11 +1,11 @@
1
1
  'use strict';
2
+ import { liveResendsByOriginal } from '../services/steer-resend.mjs';
2
3
  import { killTmuxSession } from '../services/session-worker-launch-service.mjs';
3
4
 
4
5
  import fs from 'fs';
5
6
  import path from 'path';
6
7
  import { fileURLToPath } from 'url';
7
8
  import { randomUUID } from 'crypto';
8
- import { spawn } from 'child_process';
9
9
  import { createSdkSessionSyncService } from '../services/sdk-session-sync-service.mjs';
10
10
  import { applySafeServedContentHeaders } from '../services/safe-served-content.mjs';
11
11
  import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
@@ -35,6 +35,8 @@ import {
35
35
  import { workspaceRootDisplayName } from '../workspace-root.mjs';
36
36
  import { createKeyedMutex } from '../services/keyed-mutex-service.mjs';
37
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';
38
40
  import {
39
41
  RELAUNCH_COALESCE_WINDOW_MS,
40
42
  buildRelaunchRequestKey,
@@ -685,26 +687,6 @@ function isOpenAIImageModelId(model = '') {
685
687
  return normalized.startsWith('gpt-image-') || normalized.startsWith('dall-e-');
686
688
  }
687
689
 
688
- function runHostSuspendToRam() {
689
- if (process.platform !== 'win32') {
690
- return { ok: false, statusCode: 501, error: 'Host suspend is only supported on Windows' };
691
- }
692
- try {
693
- const child = spawn('rundll32.exe', ['powrprof.dll,SetSuspendState', '0,0,0'], {
694
- detached: true,
695
- stdio: 'ignore',
696
- windowsHide: true,
697
- });
698
- child.unref?.();
699
- return {
700
- ok: true,
701
- command: 'rundll32.exe powrprof.dll,SetSuspendState 0,0,0',
702
- };
703
- } catch (error) {
704
- return { ok: false, statusCode: 500, error: error?.message || 'Failed to launch host suspend command' };
705
- }
706
- }
707
-
708
690
  function isPidAlive(pidValue) {
709
691
  const pid = Number(pidValue);
710
692
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -947,13 +929,19 @@ export function normalizePreferredReasoningEffort(value) {
947
929
  return String(value || '').trim().toLowerCase();
948
930
  }
949
931
 
950
- const MAX_CONVERSATION_DRAFT_LENGTH = 20_000;
932
+ // The web client caps what it saves at the same length (MAX_DRAFT_TEXT_LENGTH
933
+ // in public/app/conversation-draft-sync.mjs); a test pins them together.
934
+ export const MAX_CONVERSATION_DRAFT_LENGTH = 20_000;
951
935
 
952
936
  function normalizeConversationDraftText(value, { maxLength = MAX_CONVERSATION_DRAFT_LENGTH } = {}) {
953
937
  const text = String(value ?? '');
954
938
  if (!text.trim()) return '';
955
939
  if (text.length <= maxLength) return text;
956
- return text.slice(0, maxLength);
940
+ // Never split a surrogate pair: SQLite would store the lone half as U+FFFD,
941
+ // and the client (which cuts the same way) would no longer match its draft.
942
+ const cut = text.slice(0, maxLength);
943
+ const last = cut.charCodeAt(cut.length - 1);
944
+ return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
957
945
  }
958
946
 
959
947
  export function normalizeOptionalIsoTimestamp(value) {
@@ -1743,17 +1731,24 @@ export function buildConversationMessages({
1743
1731
  .map((row) => [String(row?.id || '').trim(), row])
1744
1732
  .filter(([id]) => !!id),
1745
1733
  );
1734
+ // A stopped steer's marker says "Resent" from the data, so it holds across
1735
+ // reloads and devices (the relay refuses a second Resend on the same basis).
1736
+ const liveResends = liveResendsByOriginal(dbMessages, queueRows);
1746
1737
  const normalizedDbMessages = Array.isArray(dbMessages)
1747
1738
  ? dbMessages.map((message) => {
1748
1739
  const id = String(message?.id || '').trim();
1740
+ // The queue's link is pruned with old rows; the message's own column
1741
+ // keeps it, so a reload still anchors the reply under its prompt.
1749
1742
  const sourceMessageId = message?.role === 'assistant'
1750
- ? (responseMessageToSourceId.get(id) || undefined)
1743
+ ? (responseMessageToSourceId.get(id) || String(message?.source_message_id || '').trim() || undefined)
1751
1744
  : undefined;
1752
1745
  // A user row's queue entry shares its id; assistant rows reach the same
1753
1746
  // entry through the response mapping. Both need it for the effort tag.
1754
1747
  const queueRow = sourceMessageId
1755
1748
  ? queueRowsById.get(sourceMessageId)
1756
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;
1757
1752
  return {
1758
1753
  activities: message?.role === 'assistant' ? (relayActivitiesByMessageId.get(id) || []) : [],
1759
1754
  thoughts: message?.role === 'assistant' ? (relayThoughtsByMessageId.get(id) || []) : [],
@@ -1775,6 +1770,16 @@ export function buildConversationMessages({
1775
1770
  timestamp: message?.timestamp,
1776
1771
  sourceMessageId,
1777
1772
  executedProvider: message?.executed_provider || message?.executedProvider || undefined,
1773
+ // 'continuation' badges a self-started turn; 'absorbed' marks a
1774
+ // reply that continues through the next (steered) user message;
1775
+ // 'folded'/'stopped' are a steer's settle markers. Without this the
1776
+ // live-appended badge/merge/marker vanished on reload.
1777
+ kind: message?.kind || undefined,
1778
+ resendOfMessageId: message?.role === 'user' ? (message?.resend_of_message_id || undefined) : undefined,
1779
+ resentAs: (message?.role === 'assistant' && message?.kind === 'stopped' && sourceMessageId)
1780
+ ? (liveResends.get(sourceMessageId) || undefined)
1781
+ : undefined,
1782
+ ...(origin ? { origin } : {}),
1778
1783
  };
1779
1784
  })
1780
1785
  : [];
@@ -2084,6 +2089,13 @@ export function registerSessionsRoutes(app, deps) {
2084
2089
  windowsBootAutostartService,
2085
2090
  isSha256,
2086
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,
2087
2099
  } = deps;
2088
2100
  const sdkSessionSyncService = createSdkSessionSyncService(db);
2089
2101
  // Generic synchronous transaction runner for the session-sync route: binding
@@ -2112,9 +2124,11 @@ export function registerSessionsRoutes(app, deps) {
2112
2124
  }
2113
2125
  }
2114
2126
 
2115
- const SDK_DELETE_WAIT_TIMEOUT_MS = 12_000;
2116
- const SDK_DELETE_POLL_MS = 200;
2117
2127
  const SDK_DELETE_STALE_PROCESSING_MS = 60_000;
2128
+ const CLI_SESSION_DELETE_TIMEOUT_MS = 10_000;
2129
+ const LIVE_WORKER_STATUSES = new Set(['starting', 'ready', 'processing']);
2130
+ // A session id that is safe to join into a path under the session-state root.
2131
+ const SESSION_FOLDER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
2118
2132
  const markConversationDeleted = db.prepare(`UPDATE conversations SET status = 'deleted', updated_at = ? WHERE id = ?`);
2119
2133
  const getActiveQueueForMessage = db.prepare(`
2120
2134
  SELECT id
@@ -2143,6 +2157,20 @@ export function registerSessionsRoutes(app, deps) {
2143
2157
  WHERE conversation_id = ?
2144
2158
  AND status IN ('pending', 'processing', 'parked')
2145
2159
  `);
2160
+ const countProcessingConversationQueueRows = db.prepare(`
2161
+ SELECT COUNT(*) AS count
2162
+ FROM queue
2163
+ WHERE conversation_id = ?
2164
+ AND status = 'processing'
2165
+ `);
2166
+ const findOtherLiveConversationForSdkSession = db.prepare(`
2167
+ SELECT id
2168
+ FROM conversations
2169
+ WHERE sdk_session_id = ?
2170
+ AND id != ?
2171
+ AND status != 'deleted'
2172
+ LIMIT 1
2173
+ `);
2146
2174
  const getConversationUsageSnapshotAggregate = db.prepare(`
2147
2175
  SELECT
2148
2176
  COUNT(*) AS snapshot_count,
@@ -2206,10 +2234,6 @@ export function registerSessionsRoutes(app, deps) {
2206
2234
  const sessionLaunchMutex = createKeyedMutex();
2207
2235
  const relaunchCoalescer = createRelaunchCoalescer({ windowMs: RELAUNCH_COALESCE_WINDOW_MS });
2208
2236
 
2209
- function sleep(ms) {
2210
- return new Promise((resolve) => setTimeout(resolve, ms));
2211
- }
2212
-
2213
2237
  // Layer every enabled provider's models onto the base Copilot catalog.
2214
2238
  function buildModelCatalogWithProviders(modelState, openAISettings = getOpenAIProviderSettings()) {
2215
2239
  return buildModelCatalogWithGrokProvider(
@@ -2292,16 +2316,121 @@ export function registerSessionsRoutes(app, deps) {
2292
2316
  return true;
2293
2317
  }
2294
2318
 
2295
- async function waitForSdkDeleteCompletion(sdkSessionId, timeoutMs = SDK_DELETE_WAIT_TIMEOUT_MS) {
2296
- const sid = String(sdkSessionId || '').trim();
2297
- if (!sid) return { completed: false };
2298
- const startedAt = Date.now();
2299
- while ((Date.now() - startedAt) < timeoutMs) {
2300
- const row = stmts.getSdkDeleteRequestBySessionId.get(sid);
2301
- if (!row) return { completed: true };
2302
- await sleep(SDK_DELETE_POLL_MS);
2303
- }
2304
- return { completed: false };
2319
+ /**
2320
+ * Whether a conversation is still working, which blocks deleting it: a turn
2321
+ * is running (including one waiting on a question card or an approval) or
2322
+ * background tasks are live. Both only count while the session's worker is
2323
+ * alive: a dead worker runs nothing, and the processing rows or task set it
2324
+ * left behind would otherwise make the conversation undeletable. A session
2325
+ * the registry has never seen may still be running a turn, so its
2326
+ * processing rows count.
2327
+ *
2328
+ * A worker's pid decides whether it is alive; its status only when it has
2329
+ * none. A turn that fails marks a worker that is still running 'error',
2330
+ * and it stays so until its next turn, background tasks and all.
2331
+ */
2332
+ function describeConversationActivity(conversationId, workerSessionId) {
2333
+ const worker = sessionWorkerRegistry?.getWorker?.(workerSessionId) || null;
2334
+ const workerAlive = !!worker && (worker.pid
2335
+ ? isPidAlive(worker.pid)
2336
+ : LIVE_WORKER_STATUSES.has(String(worker.status || '').trim().toLowerCase()));
2337
+ const processingCount = Number(countProcessingConversationQueueRows.get(conversationId)?.count || 0);
2338
+ const taskKeys = [...new Set([conversationId, workerSessionId])];
2339
+ const liveTasks = workerAlive ? taskKeys.flatMap((key) => backgroundTaskStore?.get?.(key) || []) : [];
2340
+ return {
2341
+ turnRunning: processingCount > 0 && (workerAlive || !worker),
2342
+ backgroundTaskCount: liveTasks.length,
2343
+ // Tasks the panel shows no Stop button for (Copilot detached shells).
2344
+ unstoppableTaskCount: liveTasks.filter((task) => task?.stoppable === false).length,
2345
+ };
2346
+ }
2347
+
2348
+ function withDeadline(promise, timeoutMs, label) {
2349
+ let timer = null;
2350
+ return Promise.race([
2351
+ promise,
2352
+ new Promise((_, reject) => {
2353
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs} ms`)), timeoutMs);
2354
+ timer.unref?.();
2355
+ }),
2356
+ ]).finally(() => clearTimeout(timer));
2357
+ }
2358
+
2359
+ /**
2360
+ * The Claude CLI's footprint for one session: its transcript
2361
+ * (`<projectDir>/<nativeSessionId>.jsonl`) and the per-session folder beside
2362
+ * it. Nothing else in the project folder is touched — it holds every other
2363
+ * session of that workspace and the project memory.
2364
+ */
2365
+ function deleteClaudeCliSession({ conversation, runtimeSession }) {
2366
+ const nativeSessionId = String(runtimeSession?.claude_native_session_id || '').trim();
2367
+ if (!nativeSessionId || typeof resolveClaudeSessionRoot !== 'function') return { deleted: null };
2368
+ const anchors = resolveClaudeSessionRoot({
2369
+ claudeNativeSessionId: nativeSessionId,
2370
+ workspaceRootPath: String(
2371
+ conversation?.runtime_workspace_root_path || conversation?.configured_workspace_root_path || '',
2372
+ ).trim(),
2373
+ });
2374
+ if (!anchors?.projectDirPath) return { deleted: null };
2375
+ const projectDir = path.resolve(anchors.projectDirPath);
2376
+ const targets = [anchors.transcriptPath, anchors.sessionRootExists ? anchors.sessionRootPath : '']
2377
+ .filter(Boolean)
2378
+ .map((target) => path.resolve(target))
2379
+ .filter((target) => path.dirname(target) === projectDir
2380
+ && [nativeSessionId, `${nativeSessionId}.jsonl`].includes(path.basename(target)));
2381
+ for (const target of targets) fs.rmSync(target, { recursive: true, force: true });
2382
+ return { deleted: targets.length > 0 };
2383
+ }
2384
+
2385
+ /**
2386
+ * The Copilot session, through the runtime's own deleteSession(). When that
2387
+ * fails the request goes on the SDK delete queue, which a CLI-side consumer
2388
+ * (the extension engine) still works off.
2389
+ */
2390
+ async function deleteCopilotCliSession({ sdkSessionId, conversationId }) {
2391
+ const stateRoot = String(resolveSessionStateRoot?.() || '').trim();
2392
+ const sessionDir = stateRoot && SESSION_FOLDER_ID_PATTERN.test(sdkSessionId) ? path.join(stateRoot, sdkSessionId) : '';
2393
+ // A conversation deleted before its first turn has no session on disk yet
2394
+ // (the runtime would answer "Session file not found").
2395
+ if (sessionDir && !fs.existsSync(sessionDir)) return { deleted: null };
2396
+ if (typeof sdkSessionImportService?.deleteSession !== 'function') return { deleted: null };
2397
+ try {
2398
+ await withDeadline(
2399
+ sdkSessionImportService.deleteSession(sdkSessionId),
2400
+ CLI_SESSION_DELETE_TIMEOUT_MS,
2401
+ 'Copilot deleteSession()',
2402
+ );
2403
+ return { deleted: true };
2404
+ } catch (error) {
2405
+ const reason = String(error?.message || error);
2406
+ if (/Session file not found/i.test(reason)) {
2407
+ // The runtime keeps no session for it, only this session's own
2408
+ // folder (a run that never wrote its session file): nothing to retry.
2409
+ if (sessionDir) fs.rmSync(sessionDir, { recursive: true, force: true });
2410
+ return { deleted: sessionDir ? true : null };
2411
+ }
2412
+ console.warn(`[delete] Copilot session ${shortId(sdkSessionId)} was not deleted: ${reason}`);
2413
+ enqueueSdkDeleteRequest(sdkSessionId, conversationId);
2414
+ return { deleted: false, queued: true };
2415
+ }
2416
+ }
2417
+
2418
+ /** The conversation's CLI-side session, per provider; null where the relay keeps none. */
2419
+ async function deleteCliSession({ conversation, conversationId, sdkSessionId, runtimeSession }) {
2420
+ const provider = String(runtimeSession?.provider_type || '').trim().toLowerCase();
2421
+ try {
2422
+ if (provider === 'claude') return deleteClaudeCliSession({ conversation, runtimeSession });
2423
+ if (!sdkSessionId) return { deleted: null };
2424
+ if (!provider || provider === 'github' || provider === 'openai') {
2425
+ return await deleteCopilotCliSession({ sdkSessionId, conversationId });
2426
+ }
2427
+ return { deleted: null };
2428
+ } catch (error) {
2429
+ // The conversation is already tombstoned: a file the OS will not let go
2430
+ // of must not leave it half deleted.
2431
+ console.warn(`[delete] CLI session of ${shortId(conversationId)} was not deleted: ${error?.message || error}`);
2432
+ return { deleted: false };
2433
+ }
2305
2434
  }
2306
2435
 
2307
2436
  function finalizeDeletedConversationsForSdkSession(sdkSessionId) {
@@ -2436,7 +2565,7 @@ export function registerSessionsRoutes(app, deps) {
2436
2565
  (stmts.getSharedMessages || stmts.getMessages).all(resolvedConversationId),
2437
2566
  );
2438
2567
  const queueRows = db.prepare(`
2439
- SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model
2568
+ SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
2440
2569
  FROM queue
2441
2570
  WHERE conversation_id = ?
2442
2571
  `).all(resolvedConversationId);
@@ -2495,7 +2624,11 @@ export function registerSessionsRoutes(app, deps) {
2495
2624
  });
2496
2625
  messages = messages.map((message) => {
2497
2626
  const sourceMessageId = responseMessageToSourceId.get(String(message.id || '').trim()) || message.sourceMessageId || undefined;
2498
- 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;
2499
2632
  const attachments = Array.isArray(nextMessage?.attachments)
2500
2633
  ? nextMessage.attachments.map((attachment) => rewriteSharedGeneratedImageAttachmentContentUrl(rewriteSharedAttachmentContentUrl(attachment, shareToken), shareToken))
2501
2634
  : nextMessage?.attachments;
@@ -2644,6 +2777,8 @@ export function registerSessionsRoutes(app, deps) {
2644
2777
  draftAttachments: parseDraftAttachmentsColumn(r.draft_attachments),
2645
2778
  draftUpdatedAt: r.draft_updated_at || null,
2646
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),
2647
2782
  };
2648
2783
  });
2649
2784
 
@@ -3181,7 +3316,7 @@ export function registerSessionsRoutes(app, deps) {
3181
3316
  });
3182
3317
  const dbMessages = stmts.getMessages.all(conversationId);
3183
3318
  const queueRows = db.prepare(`
3184
- SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model
3319
+ SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
3185
3320
  FROM queue
3186
3321
  WHERE conversation_id = ?
3187
3322
  `).all(conversationId);
@@ -3285,6 +3420,7 @@ export function registerSessionsRoutes(app, deps) {
3285
3420
  draftAttachments: parseDraftAttachmentsColumn(conv.draft_attachments),
3286
3421
  draftUpdatedAt: conv.draft_updated_at || null,
3287
3422
  draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
3423
+ origin: parseRemoteRelayOriginJson(conv.origin_json),
3288
3424
  messages: history.messages,
3289
3425
  pageInfo: history.pageInfo,
3290
3426
  refreshed: true,
@@ -3583,7 +3719,7 @@ export function registerSessionsRoutes(app, deps) {
3583
3719
  });
3584
3720
  const dbMessages = stmts.getMessages.all(resolvedConversationId);
3585
3721
  const queueRows = db.prepare(`
3586
- SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model
3722
+ SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
3587
3723
  FROM queue
3588
3724
  WHERE conversation_id = ?
3589
3725
  `).all(resolvedConversationId);
@@ -3680,6 +3816,10 @@ export function registerSessionsRoutes(app, deps) {
3680
3816
  updatedAt: conv.updated_at,
3681
3817
  sessionUsageSummary,
3682
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,
3683
3823
  backgroundTasks: backgroundTaskStore?.get?.(resolvedConversationId) || [],
3684
3824
  previews: listConversationPreviews?.(resolvedConversationId) || [],
3685
3825
  preferredRelayMode: preferences.preferredRelayMode,
@@ -3689,6 +3829,8 @@ export function registerSessionsRoutes(app, deps) {
3689
3829
  draftAttachments: parseDraftAttachmentsColumn(conv.draft_attachments),
3690
3830
  draftUpdatedAt: conv.draft_updated_at || null,
3691
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),
3692
3834
  messages: history.messages,
3693
3835
  pageInfo: history.pageInfo,
3694
3836
  });
@@ -3854,15 +3996,30 @@ export function registerSessionsRoutes(app, deps) {
3854
3996
  if (!existing || String(existing.status || '').trim() === 'deleted') {
3855
3997
  return res.status(404).json({ error: 'Conversation not found' });
3856
3998
  }
3857
- const baseDraftUpdatedAtValue = req.body?.baseDraftUpdatedAt ?? req.body?.base_draft_updated_at;
3858
- const comparesDraftVersion = baseDraftUpdatedAtValue !== undefined;
3999
+ // A null base means "I have seen no draft" and conflicts with any versioned
4000
+ // draft — but only from clients that declare the versioned protocol. Older
4001
+ // tabs also send an explicit null (after every keystroke), and a 409 there
4002
+ // would revert their typing, so for them null stays unconditional.
4003
+ const baseDraftUpdatedAtValue = req.body?.baseDraftUpdatedAt !== undefined
4004
+ ? req.body.baseDraftUpdatedAt
4005
+ : req.body?.base_draft_updated_at;
4006
+ const speaksVersionedDraftProtocol = Number(req.body?.draftSyncVersion) >= 2;
4007
+ const comparesDraftVersion = baseDraftUpdatedAtValue !== undefined
4008
+ && (baseDraftUpdatedAtValue !== null || speaksVersionedDraftProtocol);
3859
4009
  const normalizedBaseDraftUpdatedAt = normalizeOptionalIsoTimestamp(baseDraftUpdatedAtValue);
3860
4010
  const suppliedBaseTimestamp = String(baseDraftUpdatedAtValue ?? '').trim();
3861
4011
  if (comparesDraftVersion && suppliedBaseTimestamp && !normalizedBaseDraftUpdatedAt) {
3862
4012
  return res.status(400).json({ error: 'Invalid baseDraftUpdatedAt timestamp' });
3863
4013
  }
3864
4014
  const existingDraftUpdatedAt = normalizeOptionalIsoTimestamp(existing.draft_updated_at);
3865
- if (hasConversationDraftVersionConflict({
4015
+ // A pagehide copy taken while this client's own send was in flight carries
4016
+ // the pre-send base; the send transaction has since moved the version. That
4017
+ // move (recorded with the sender's client id) is the only mismatch it may
4018
+ // pass — a newer draft from anyone else still conflicts.
4019
+ const acceptsOwnSendVersion = req.body?.acceptOwnSend === true
4020
+ && !!senderClientId
4021
+ && String(existing.draft_updated_by_client_id || '') === senderClientId;
4022
+ if (!acceptsOwnSendVersion && hasConversationDraftVersionConflict({
3866
4023
  existingDraftUpdatedAt,
3867
4024
  baseDraftUpdatedAt: normalizedBaseDraftUpdatedAt,
3868
4025
  compareEnabled: comparesDraftVersion,
@@ -3874,6 +4031,7 @@ export function registerSessionsRoutes(app, deps) {
3874
4031
  conflict: true,
3875
4032
  conversationId,
3876
4033
  draftText: String(existing.draft_text || ''),
4034
+ draftAttachments: parseDraftAttachmentsColumn(existing.draft_attachments),
3877
4035
  draftUpdatedAt: existingDraftUpdatedAt,
3878
4036
  draftUpdatedByClientId: existing.draft_updated_by_client_id || null,
3879
4037
  });
@@ -4275,44 +4433,135 @@ export function registerSessionsRoutes(app, deps) {
4275
4433
  }
4276
4434
 
4277
4435
  try {
4278
- const sdkSessionId = String(existing.sdk_session_id || '').trim() || null;
4279
- if (!sdkSessionId) {
4280
- stmts.markDeletedSdkSession.run(id, new Date().toISOString());
4281
- cleanupGeneratedImagesForConversation({
4282
- conversationId: id,
4283
- sdkSessionId: null,
4284
- messageRows: stmts.getMessages?.all?.(id) || [],
4285
- parseAttachments,
4286
- hydrateAttachment,
4287
- resolveSessionStateRoot,
4288
- });
4289
- const orphanedUploads = collectOrphanedUploadsFromConversation(id);
4290
- hardDeleteConversationRows(id);
4291
- deleteOrphanedUploads(orphanedUploads);
4292
- io.emit('conversation_deleted', { conversationId: id });
4293
- return res.json({ ok: true });
4294
- }
4295
-
4296
- markConversationDeleted.run(new Date().toISOString(), id);
4297
- stmts.markDeletedSdkSession.run(sdkSessionId, new Date().toISOString());
4298
- if (sdkSessionId !== id) {
4299
- // Also tombstone the conversation id itself for legacy/synthetic sdk id fallback paths.
4300
- stmts.markDeletedSdkSession.run(id, new Date().toISOString());
4301
- }
4302
- enqueueSdkDeleteRequest(sdkSessionId, id);
4303
- const awaited = await waitForSdkDeleteCompletion(sdkSessionId);
4304
- if (awaited.completed) return res.json({ ok: true });
4305
-
4306
- io.emit('conversation_delete_pending', { conversationId: id });
4307
- return res.json({ ok: true, pending: true });
4436
+ // Session workers are keyed by the SDK session id, which is the
4437
+ // conversation id until a session is bound. Serialized with launches and
4438
+ // relaunches of that session, and with a second delete of it.
4439
+ const workerSessionId = String(existing.sdk_session_id || '').trim() || id;
4440
+ const outcome = await sessionLaunchMutex.runExclusive(
4441
+ workerSessionId,
4442
+ () => deleteConversationNow(id),
4443
+ );
4444
+ return res.status(outcome.statusCode).json(outcome.body);
4308
4445
  } catch (error) {
4309
4446
  console.warn(`[archive] Delete failed for ${id}: ${error?.message || error}`);
4310
4447
  return res.status(500).json({ error: 'Failed to delete conversation' });
4311
4448
  }
4312
4449
  });
4313
4450
 
4451
+ /** DELETE /api/conversation/:id under its session's lock; see the route. */
4452
+ async function deleteConversationNow(id) {
4453
+ const existing = stmts.getConvAnyStatus.get(id);
4454
+ if (!existing) return { statusCode: 200, body: { ok: true, alreadyDeleted: true } };
4455
+ const sdkSessionId = String(existing.sdk_session_id || '').trim() || null;
4456
+ const workerSessionId = sdkSessionId || id;
4457
+
4458
+ const activity = describeConversationActivity(id, workerSessionId);
4459
+ if (activity.turnRunning) {
4460
+ return {
4461
+ statusCode: 409,
4462
+ body: {
4463
+ ok: false,
4464
+ error: 'conversation-active',
4465
+ reason: 'turn-running',
4466
+ message: 'This conversation is still working on a reply. Stop it, then delete it.',
4467
+ },
4468
+ };
4469
+ }
4470
+ if (activity.backgroundTaskCount > 0) {
4471
+ return {
4472
+ statusCode: 409,
4473
+ body: {
4474
+ ok: false,
4475
+ error: 'conversation-active',
4476
+ reason: 'background-tasks',
4477
+ // Kill session stops the session's worker, and the delete counts no
4478
+ // tasks for a conversation whose worker is gone.
4479
+ message: activity.unstoppableTaskCount > 0
4480
+ ? 'Background tasks are still running in this conversation, and some have no Stop button. Use Kill session in its ⋯ menu, then delete it.'
4481
+ : 'Background tasks are still running in this conversation. Stop them in the task panel, then delete it.',
4482
+ },
4483
+ };
4484
+ }
4485
+
4486
+ // A worker and CLI session that another live conversation still uses
4487
+ // stay; only this conversation's own data goes.
4488
+ const sharedWithConversationId = sdkSessionId
4489
+ ? String(findOtherLiveConversationForSdkSession.get(sdkSessionId, id)?.id || '').trim() || null
4490
+ : null;
4491
+ // A Copilot session the relay only imported, and never ran, belongs to
4492
+ // whoever started it in the CLI. Deleting the conversation hides and
4493
+ // tombstones it, and nothing more: the relay started no process for it
4494
+ // (the process scan would find that user's own `copilot --resume`), and
4495
+ // its CLI session stays. Asked before the stop, which removes the registry
4496
+ // entry that shows the relay has run it.
4497
+ const importedOnly = !!sdkSessionId && sdkSessionImportService?.isImportedOnly?.(sdkSessionId) === true;
4498
+
4499
+ // An idle worker is stopped before anything is deleted: the route used to
4500
+ // leave it running (and holding its workspace) until the relay restarted.
4501
+ // Nothing is touched when it will not die.
4502
+ let workerStopped = false;
4503
+ if (!sharedWithConversationId && !importedOnly) {
4504
+ const stopped = await stopIdleWorkspaceRootSession({
4505
+ sdkSessionId: workerSessionId,
4506
+ worker: sessionWorkerRegistry?.getWorker?.(workerSessionId) || null,
4507
+ sessionWorkerSupervisor,
4508
+ sessionWorkerRegistry,
4509
+ sessionWorkerProcessInspector,
4510
+ stopOverrides: sessionWorkerStopOverrides,
4511
+ });
4512
+ if (!stopped.ok) {
4513
+ return {
4514
+ statusCode: 409,
4515
+ body: {
4516
+ ok: false,
4517
+ error: 'worker-stop-failed',
4518
+ message: "Couldn't stop this conversation's session, so nothing was deleted. Try again in a moment.",
4519
+ remainingPids: stopped.remainingPids || [],
4520
+ },
4521
+ };
4522
+ }
4523
+ workerStopped = (stopped.stoppedPids || []).length > 0;
4524
+ // Re-armed after the stop, which can outlast the kill block's grace
4525
+ // window: nothing may respawn a worker for the rows deleted below.
4526
+ sessionWorkerSupervisor?.markKilled?.(workerSessionId);
4527
+ for (const key of new Set([id, workerSessionId])) backgroundTaskStore?.replace?.(key, []);
4528
+ }
4529
+
4530
+ const deletedAt = new Date().toISOString();
4531
+ markConversationDeleted.run(deletedAt, id);
4532
+ // Tombstones keep the SDK session importer from bringing it back.
4533
+ stmts.markDeletedSdkSession.run(sdkSessionId || id, deletedAt);
4534
+ if (sdkSessionId && sdkSessionId !== id) stmts.markDeletedSdkSession.run(id, deletedAt);
4535
+
4536
+ const cliSession = sharedWithConversationId || importedOnly
4537
+ ? { deleted: null }
4538
+ : await deleteCliSession({
4539
+ conversation: existing,
4540
+ conversationId: id,
4541
+ sdkSessionId,
4542
+ runtimeSession: stmts.getRuntimeSessionByConversation?.get?.(id) || null,
4543
+ });
4544
+
4545
+ cleanupGeneratedImagesForConversation({
4546
+ conversationId: id,
4547
+ sdkSessionId,
4548
+ messageRows: stmts.getMessages?.all?.(id) || [],
4549
+ parseAttachments,
4550
+ hydrateAttachment,
4551
+ resolveSessionStateRoot,
4552
+ });
4553
+ const orphanedUploads = collectOrphanedUploadsFromConversation(id);
4554
+ hardDeleteConversationRows(id);
4555
+ remoteRelayInbound?.repository?.forgetConversation?.(id);
4556
+ deleteOrphanedUploads(orphanedUploads);
4557
+ if (sdkSessionId && !cliSession.queued) stmts.deleteSdkDeleteRequest?.run?.(sdkSessionId);
4558
+ io.emit('conversation_deleted', { conversationId: id });
4559
+ return { statusCode: 200, body: { ok: true, workerStopped, cliSessionDeleted: cliSession.deleted } };
4560
+ }
4561
+
4314
4562
  // POST /api/conversation/:id/archive — archive conversation
4315
4563
  app.post('/api/conversation/:id/archive', auth, async (req, res) => {
4564
+ if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
4316
4565
  const id = String(req.params.id || '').trim();
4317
4566
  if (!id) return res.status(400).json({ error: 'Missing conversation id' });
4318
4567
  try {
@@ -4330,6 +4579,10 @@ export function registerSessionsRoutes(app, deps) {
4330
4579
  });
4331
4580
 
4332
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;
4333
4586
  const clientSessionId = String(ensureSessionId(req, res) || '').trim();
4334
4587
  const now = new Date().toISOString();
4335
4588
  let conversationId = '';
@@ -4625,6 +4878,7 @@ export function registerSessionsRoutes(app, deps) {
4625
4878
  // single canonical tuple.
4626
4879
  const commitBootstrapState = db.transaction(() => {
4627
4880
  stmts.insertConv.run(conversationId, title, now, now);
4881
+ if (remoteRequest.origin) remoteRelayInbound?.repository?.setConversationOrigin?.(conversationId, remoteRequest.origin);
4628
4882
  const boundRuntimeSession = ensureRuntimeSessionBinding(
4629
4883
  conversationId,
4630
4884
  selectedModel,
@@ -4744,6 +4998,7 @@ export function registerSessionsRoutes(app, deps) {
4744
4998
  ok: true,
4745
4999
  conversationId,
4746
5000
  conversation,
5001
+ origin: parseRemoteRelayOriginJson(conversation?.origin_json),
4747
5002
  runtimeSessionId: runtimeSession?.id || null,
4748
5003
  ownerSessionId,
4749
5004
  worker: ownerWorker,
@@ -4845,6 +5100,7 @@ export function registerSessionsRoutes(app, deps) {
4845
5100
  activeBridgeOwner: runtimeState.activeBridgeOwner || null,
4846
5101
  restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
4847
5102
  relayShutdown: runtimeState.relayShutdown || null,
5103
+ hostSuspend: runtimeState.hostSuspend || null,
4848
5104
  platform: process.platform,
4849
5105
  features: featureFlags || {},
4850
5106
  sessionWorker: sessionWorkerStatus,
@@ -5939,21 +6195,59 @@ export function registerSessionsRoutes(app, deps) {
5939
6195
  });
5940
6196
  });
5941
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
+
5942
6223
  app.post('/api/host/suspend', auth, (req, res) => {
5943
- const result = runHostSuspendToRam();
5944
- if (!result?.ok) {
5945
- return res.status(result?.statusCode || 500).json({
5946
- ok: false,
5947
- error: result?.error || 'Failed to suspend host',
5948
- });
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' });
5949
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 });
5950
6233
  return res.status(202).json({
5951
6234
  ok: true,
5952
6235
  queued: true,
5953
- command: result.command,
6236
+ accepted: result.accepted !== false,
6237
+ alreadyPending: !!result.alreadyPending,
6238
+ state: result.state,
5954
6239
  });
5955
6240
  });
5956
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
+
5957
6251
  app.get('/api/restart-orchestrator', auth, (req, res) => {
5958
6252
  ensureSessionId(req, res);
5959
6253
  return res.json({ orchestrator: relayRestartOrchestrator?.getState?.() || null });