@oar-sh/oar 0.9.3 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +98 -0
  4. package/README.md +76 -14
  5. package/package.json +5 -2
  6. package/server/README.md +43 -3
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +100 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  9. package/server/claude-worker/claude-session-process.mjs +385 -43
  10. package/server/claude-worker/claude-session-worker.mjs +5 -0
  11. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  12. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  13. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
  15. package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
  16. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  17. package/server/cursor-worker/cursor-session-worker.mjs +5 -0
  18. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  19. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  20. package/server/db-schema.mjs +8 -0
  21. package/server/grok-worker/acp-client.mjs +92 -6
  22. package/server/grok-worker/grok-session-worker.mjs +5 -0
  23. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  24. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  25. package/server/mcp/oar-mcp-server.mjs +473 -0
  26. package/server/migrations/0005-remote-relays.mjs +90 -0
  27. package/server/public/app/action-confirmations.js +141 -13
  28. package/server/public/app/api-client.js +103 -0
  29. package/server/public/app/bootstrap.js +21 -6
  30. package/server/public/app/conversation-deep-link.mjs +42 -0
  31. package/server/public/app/conversation-view.js +100 -7
  32. package/server/public/app/host-suspend-ui.js +272 -0
  33. package/server/public/app/journal-view.js +4 -1
  34. package/server/public/app/mention-autocomplete.mjs +298 -0
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/pwa-install.js +8 -3
  37. package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
  38. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  39. package/server/public/app/remote-relay-shared.mjs +85 -0
  40. package/server/public/app/remote-relays-settings.js +618 -0
  41. package/server/public/app/remote-relays-store.mjs +126 -0
  42. package/server/public/app/settings-modal.js +4 -2
  43. package/server/public/app/settings-tabs.js +1 -1
  44. package/server/public/app/socket-handlers.js +18 -1
  45. package/server/public/app/steered-turn-merge.mjs +10 -1
  46. package/server/public/app/thinking-follow.js +260 -0
  47. package/server/public/app/tool-activity-emoji.mjs +4 -0
  48. package/server/public/index.html +264 -11
  49. package/server/relay-tools.md +5 -1
  50. package/server/repositories/message-repository.mjs +7 -0
  51. package/server/repositories/question-repository.mjs +3 -0
  52. package/server/repositories/remote-relay-repository.mjs +75 -0
  53. package/server/repositories/session-repository.mjs +6 -1
  54. package/server/routes/ask-user-routes.mjs +42 -2
  55. package/server/routes/messages-routes.mjs +98 -19
  56. package/server/routes/remote-relay-routes.mjs +133 -0
  57. package/server/routes/sessions-routes.mjs +84 -31
  58. package/server/server-runtime.mjs +238 -0
  59. package/server/services/github-ci-activity-service.mjs +199 -0
  60. package/server/services/host-suspend-command.mjs +38 -0
  61. package/server/services/host-suspend-service.mjs +254 -0
  62. package/server/services/push-dispatch-service.mjs +37 -4
  63. package/server/services/remote-relay-caller-context.mjs +346 -0
  64. package/server/services/remote-relay-client.mjs +332 -0
  65. package/server/services/remote-relay-dispatcher.mjs +1743 -0
  66. package/server/services/remote-relay-inbound.mjs +187 -0
  67. package/server/services/remote-relay-pairing.mjs +189 -0
  68. package/server/services/remote-relay-registry-service.mjs +581 -0
  69. package/server/services/session-worker-launch-service.mjs +148 -14
  70. package/shared/remote-relay-contract.mjs +556 -0
  71. package/shared/remote-relay-mentions.mjs +129 -0
  72. package/shared/remote-relay-tool-core.mjs +314 -0
  73. package/shared/steer-settle-failure.mjs +6 -0
  74. package/shared/steer-settle-markers.mjs +16 -0
  75. package/shared/worker-runtime/api-client.mjs +112 -22
  76. package/shared/worker-runtime/worker-log-file.mjs +97 -0
@@ -40,6 +40,13 @@ import { registerRelayBoardRoutes } from './routes/relay-board-routes.mjs';
40
40
  import { registerCacheRoutes } from './routes/cache-routes.mjs';
41
41
  import { registerGitRoutes } from './routes/git-routes.mjs';
42
42
  import { registerPreviewRoutes } from './routes/preview-routes.mjs';
43
+ import { registerRemoteRelayRoutes } from './routes/remote-relay-routes.mjs';
44
+ import { createRemoteRelayRepository } from './repositories/remote-relay-repository.mjs';
45
+ import { createRemoteRelayClient } from './services/remote-relay-client.mjs';
46
+ import { createRemoteRelayRegistry } from './services/remote-relay-registry-service.mjs';
47
+ import { createRemoteRelayPairing } from './services/remote-relay-pairing.mjs';
48
+ import { createRemoteRelayCallerContext } from './services/remote-relay-caller-context.mjs';
49
+ import { createRemoteRelayDispatcher } from './services/remote-relay-dispatcher.mjs';
43
50
  import { createDeleteArchiveService } from './services/delete-archive-service.mjs';
44
51
  import { createStatusEventService } from './services/status-event-service.mjs';
45
52
  import { createClaudeAuthService } from './services/claude-auth-service.mjs';
@@ -48,6 +55,9 @@ import { createGrokAuthService } from './services/grok-auth-service.mjs';
48
55
  import { sweepUnreferencedUploads, UNREFERENCED_UPLOADS_QUERY } from './services/upload-sweep.mjs';
49
56
  import webpush from 'web-push';
50
57
  import { createPushDispatchService, ensurePushVapidKeys } from './services/push-dispatch-service.mjs';
58
+ import { createHostSuspendService } from './services/host-suspend-service.mjs';
59
+ import { createGitHubCiActivityService } from './services/github-ci-activity-service.mjs';
60
+ import { runHostSuspendToRam } from './services/host-suspend-command.mjs';
51
61
  import { createActiveDeviceTracker } from './services/push-active-device-service.mjs';
52
62
  import { registerPushRoutes } from './routes/push-routes.mjs';
53
63
  import { createImageOperationService } from './services/image-operation-service.mjs';
@@ -3136,6 +3146,36 @@ function getRelayShutdownState() {
3136
3146
  };
3137
3147
  }
3138
3148
 
3149
+ // Every client shows a banner while a shutdown/restart is queued, so the
3150
+ // state goes out on each transition (request, cancel, drain) and on connect.
3151
+ function relayShutdownStatePayload() {
3152
+ return { ...getRelayShutdownState(), queue: queueCounts() };
3153
+ }
3154
+ function emitRelayShutdownState() {
3155
+ try { io.emit('relay_shutdown_state', relayShutdownStatePayload()); } catch {}
3156
+ }
3157
+
3158
+ function cancelRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
3159
+ if (runtimeShutdownStarted) {
3160
+ return { cancelled: false, status: 'shutting_down', ...relayShutdownStatePayload() };
3161
+ }
3162
+ if (!pendingRelayShutdownRequest) {
3163
+ return { cancelled: false, ...relayShutdownStatePayload() };
3164
+ }
3165
+ const dropped = pendingRelayShutdownRequest;
3166
+ pendingRelayShutdownRequest = null;
3167
+ if (runtimeTimers.shutdownDrain) {
3168
+ try { clearInterval(runtimeTimers.shutdownDrain); } catch {}
3169
+ runtimeTimers.shutdownDrain = null;
3170
+ }
3171
+ console.log(
3172
+ `${runtimeLogPrefix()}Relay ${dropped.action || 'shutdown'} request cancelled by `
3173
+ + `${String(requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api'}`,
3174
+ );
3175
+ emitRelayShutdownState();
3176
+ return { cancelled: true, dropped, ...relayShutdownStatePayload() };
3177
+ }
3178
+
3139
3179
  function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localhost-api', restart = false } = {}) {
3140
3180
  const safeReason = String(reason || 'manual-request').trim().slice(0, 140) || 'manual-request';
3141
3181
  const safeRequestedBy = String(requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api';
@@ -3188,12 +3228,14 @@ function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localh
3188
3228
  );
3189
3229
  sessionWorkerWebSocketService.emitDraining(`api-${action}:${requestInfo?.reason || 'manual-request'}`);
3190
3230
  void shutdownRuntime(`api-${action}:${requestInfo?.reason || 'manual-request'}`, { exitCode });
3231
+ emitRelayShutdownState();
3191
3232
  };
3192
3233
 
3193
3234
  if (!runtimeTimers.shutdownDrain) {
3194
3235
  runtimeTimers.shutdownDrain = setInterval(tryShutdownWhenIdle, 1000);
3195
3236
  }
3196
3237
  tryShutdownWhenIdle();
3238
+ if (pendingRelayShutdownRequest) emitRelayShutdownState();
3197
3239
  const counts = queueCounts();
3198
3240
  return {
3199
3241
  accepted: true,
@@ -4883,6 +4925,98 @@ const backgroundTaskStore = {
4883
4925
  },
4884
4926
  };
4885
4927
 
4928
+ // ─── Deferred host suspend ───────────────────────────────────────────────────
4929
+ // "💤 Suspend host" waits until nothing runs any more: no queued/processing
4930
+ // turn, no live background task of a live worker, no open CI run in a
4931
+ // workspace that was busy since the request. The relay-side signals are read
4932
+ // synchronously each tick; CI is refreshed in the background and read from
4933
+ // the last snapshot (a never-checked workspace counts as busy).
4934
+ const hostActivityWorkerStatuses = new Set(['starting', 'ready', 'processing']);
4935
+ function hostActivityPidAlive(pidValue) {
4936
+ const pid = Number(pidValue);
4937
+ if (!Number.isInteger(pid) || pid <= 0) return true;
4938
+ try {
4939
+ process.kill(pid, 0);
4940
+ return true;
4941
+ } catch (error) {
4942
+ return error?.code === 'EPERM';
4943
+ }
4944
+ }
4945
+ function hostActivityWorkerAlive(key) {
4946
+ const worker = sessionWorkerRegistry?.getWorker?.(key)
4947
+ || sessionWorkerRegistry?.getWorkerByConversationId?.(key)
4948
+ || null;
4949
+ if (!worker) return false;
4950
+ return hostActivityWorkerStatuses.has(String(worker.status || '').trim().toLowerCase())
4951
+ && hostActivityPidAlive(worker.pid);
4952
+ }
4953
+ const hostSuspendWatchedConversationIds = new Set();
4954
+ const githubCiActivityService = createGitHubCiActivityService({ logger: console });
4955
+ function hostActivityTitle(conversationId, fallbackTitle = null) {
4956
+ const title = String(fallbackTitle || stmts.getConv?.get?.(conversationId)?.title || '').trim();
4957
+ return title || String(conversationId || '').slice(0, 8);
4958
+ }
4959
+ function collectHostActivity({ request = null } = {}) {
4960
+ const blockers = [];
4961
+ const turnsByConversation = stmts.listActiveQueueCountsByConversation?.all?.() || [];
4962
+ for (const row of turnsByConversation) {
4963
+ const conversationId = String(row.conversation_id || '');
4964
+ if (!conversationId) continue;
4965
+ hostSuspendWatchedConversationIds.add(conversationId);
4966
+ const count = Number(row.cnt || 0);
4967
+ blockers.push({
4968
+ kind: 'turn',
4969
+ conversationId,
4970
+ title: hostActivityTitle(conversationId, row.title),
4971
+ count,
4972
+ detail: count > 1 ? `${count} turns running` : 'turn running',
4973
+ });
4974
+ }
4975
+ for (const [key, entry] of backgroundTaskStore.sets) {
4976
+ const tasks = Array.isArray(entry?.tasks) ? entry.tasks : [];
4977
+ if (!tasks.length || !hostActivityWorkerAlive(key)) continue;
4978
+ const worker = sessionWorkerRegistry?.getWorker?.(key) || sessionWorkerRegistry?.getWorkerByConversationId?.(key) || null;
4979
+ const conversationId = String(worker?.conversationId || key);
4980
+ hostSuspendWatchedConversationIds.add(conversationId);
4981
+ const what = tasks.slice(0, 3).map((task) => String(task?.description || task?.taskType || 'task').slice(0, 80)).join('; ');
4982
+ blockers.push({
4983
+ kind: 'background',
4984
+ conversationId,
4985
+ title: hostActivityTitle(conversationId),
4986
+ count: tasks.length,
4987
+ detail: `${tasks.length} background agent${tasks.length === 1 ? '' : 's'}: ${what}`,
4988
+ });
4989
+ }
4990
+ const runningSubagents = Number(stmts.countRunningSubagentRuns?.get?.()?.cnt || 0);
4991
+ // CI is only worth a network round-trip for workspaces that were busy since
4992
+ // the request (or, for the modal's peek, that are busy right now).
4993
+ const roots = [...hostSuspendWatchedConversationIds]
4994
+ .map((conversationId) => {
4995
+ try { return resolveConversationWorkspaceState({ conversationId })?.currentWorkspaceRootPath || ''; } catch { return ''; }
4996
+ })
4997
+ .filter(Boolean);
4998
+ if (roots.length) void githubCiActivityService.describeRuns(roots).catch(() => {});
4999
+ const ci = roots.length ? githubCiActivityService.snapshot(roots) : { repos: [], unknown: [], blockers: [] };
5000
+ blockers.push(...ci.blockers);
5001
+ if (!request && !hostSuspendService.getState().pending) hostSuspendWatchedConversationIds.clear();
5002
+ return { blockers, runningSubagents, ci };
5003
+ }
5004
+ const hostSuspendService = createHostSuspendService({
5005
+ collectActivity: collectHostActivity,
5006
+ runSuspend: () => runHostSuspendToRam({ logger: console }),
5007
+ logger: console,
5008
+ onStateChange: (state) => {
5009
+ if (state.status === 'idle') hostSuspendWatchedConversationIds.clear();
5010
+ try { io.emit('host_suspend_state', state); } catch {}
5011
+ },
5012
+ onSuspended: () => {
5013
+ void pushDispatchService.notifyHostSuspend({ body: 'All agents finished; the host is suspending now.' });
5014
+ },
5015
+ onDropped: (state, reason) => {
5016
+ void pushDispatchService.notifyHostSuspend({ body: `Queued host suspend dropped: relay ${reason || 'restarting'}.` });
5017
+ },
5018
+ });
5019
+
4886
5020
  // The live turn among a conversation's processing rows. Mid-turn steering can
4887
5021
  // leave several processing at once — the running turn, steered messages the
4888
5022
  // CLI folded into it (no output of their own), a handed-off row still settling
@@ -5391,6 +5525,13 @@ const app = express();
5391
5525
  // via config.trustProxy (e.g. a hop count or subnet).
5392
5526
  app.set('trust proxy', config.trustProxy ?? 'loopback');
5393
5527
  const httpServer = http.createServer(app);
5528
+ // Node closes an idle keep-alive connection after 5 s. A client that pools
5529
+ // connections (a fronting proxy such as Caddy or cloudflared, an API client)
5530
+ // can pick such a socket in the very moment the relay closes it; its request
5531
+ // is then reset before the relay reads a byte, and a POST is not replayed.
5532
+ // Holding idle connections longer than those pools do closes that window.
5533
+ httpServer.keepAliveTimeout = 65_000;
5534
+ httpServer.headersTimeout = 66_000;
5394
5535
  httpServer.prependListener('request', (req, _res) => {
5395
5536
  rewriteSocketIoRequestPath(req, remotePath);
5396
5537
  });
@@ -6040,6 +6181,7 @@ const runtimeState = {
6040
6181
  set relayPaused(value) { relayPaused = value; },
6041
6182
  get ttyConsoleActive() { return Boolean(ttyConsoleRuntime?.tty); },
6042
6183
  get relayShutdown() { return getRelayShutdownState(); },
6184
+ get hostSuspend() { return hostSuspendService.getState(); },
6043
6185
  get activeBridgeOwner() { return relayBridgeOwnerService.getOwner(); },
6044
6186
  get workerWebSocketStatus() { return sessionWorkerWebSocketService.status(); },
6045
6187
  get tmuxInspectorStatus() { return tmuxInspectorSocketService.status(); },
@@ -6056,6 +6198,82 @@ const windowsBootAutostartService = createWindowsBootAutostartService({
6056
6198
  });
6057
6199
 
6058
6200
  const gitChangesService = createGitChangesService();
6201
+
6202
+ // ─── Remote Relays ────────────────────────────────────────────────────────────
6203
+ // Other OAR relays this one is paired with, so its agents can work there. Built
6204
+ // before the shared route deps: the message, session and question routes read
6205
+ // the registry for mentions, unlocks and the inbound switch.
6206
+ // Null when migration 0005 failed this boot (db-schema retries it next boot):
6207
+ // remotes still list and pair, nothing unlocks until then.
6208
+ const remoteRelayRepository = (() => {
6209
+ try {
6210
+ return createRemoteRelayRepository(db);
6211
+ } catch (error) {
6212
+ console.warn(`[remote-relays] unlock storage unavailable: ${error?.message || error}`);
6213
+ return null;
6214
+ }
6215
+ })();
6216
+ const remoteRelayClient = createRemoteRelayClient({
6217
+ // A closure: the registry owning the instance id is declared just below.
6218
+ getOwnRelayId: () => remoteRelayRegistry.instanceId(),
6219
+ getOwnToken: () => config.authToken,
6220
+ });
6221
+ const remoteRelayRegistry = createRemoteRelayRegistry({
6222
+ readSetting: readAppSettingValue,
6223
+ writeSetting: (key, value) => stmts.upsertAppSetting.run(key, value, new Date().toISOString()),
6224
+ client: remoteRelayClient,
6225
+ repository: remoteRelayRepository,
6226
+ getOwnToken: () => config.authToken,
6227
+ getSelfName: getPwaAppName,
6228
+ hostname: os.hostname(),
6229
+ platform: process.platform,
6230
+ version: RUNNING_VERSION,
6231
+ emit: (event, payload) => io.emit(event, payload),
6232
+ logger: console,
6233
+ });
6234
+ const remoteRelayPairing = createRemoteRelayPairing({
6235
+ registry: remoteRelayRegistry,
6236
+ client: remoteRelayClient,
6237
+ getOwnToken: () => config.authToken,
6238
+ });
6239
+ // What the inbound routes need: no tokens, only ids, names and state.
6240
+ const remoteRelayInbound = {
6241
+ listRelays: () => remoteRelayRegistry.list().map(({ id, name, url, lastStatus, version }) => ({ id, name, url, lastStatus, version })),
6242
+ selfNames: () => [remoteRelayRegistry.selfName()],
6243
+ inboundEnabled: () => remoteRelayRegistry.getSelfSettings().inboundEnabled,
6244
+ recordUnlock: (conversationId, relayId, messageId) => remoteRelayRepository?.recordUnlock(conversationId, relayId, messageId) ?? false,
6245
+ describeRelay: (id) => {
6246
+ const relay = remoteRelayRegistry.get(id);
6247
+ return relay ? { id: relay.id, name: relay.name, online: relay.lastStatus === 'online', version: relay.version } : null;
6248
+ },
6249
+ repository: remoteRelayRepository,
6250
+ };
6251
+ // The remote_relay tool: who is calling (live turn, provider, model, mode,
6252
+ // hops) and the Allow / Deny card write actions need in ask/plan mode.
6253
+ const remoteRelayCallerContext = createRemoteRelayCallerContext({
6254
+ db,
6255
+ repository: remoteRelayRepository,
6256
+ questions: stmts,
6257
+ resolveLiveTurnQueueRow,
6258
+ formatQuestionRow,
6259
+ normalizeRelayMode,
6260
+ defaultRelayMode: DEFAULT_RELAY_MODE,
6261
+ emit: (event, payload) => io.emit(event, payload),
6262
+ notifyQuestion: (question) => pushDispatchService.notifyQuestion?.(question),
6263
+ uuid: uuidv4,
6264
+ logger: console,
6265
+ });
6266
+ const remoteRelayDispatcher = createRemoteRelayDispatcher({
6267
+ registry: remoteRelayRegistry,
6268
+ client: remoteRelayClient,
6269
+ repository: remoteRelayRepository,
6270
+ getCallerContext: (conversationId) => remoteRelayCallerContext.getCallerContext(conversationId),
6271
+ requestApproval: (request) => remoteRelayCallerContext.requestApproval(request),
6272
+ emitStatusEvent: ({ type, ...details }) => statusEventService.recordEvent(type, details),
6273
+ resolveConversationId: (id) => resolveConversationRecord({ conversationId: id, sdkSessionId: id })?.id || id,
6274
+ logger: console,
6275
+ });
6276
+
6059
6277
  const sharedRouteDeps = {
6060
6278
  auth,
6061
6279
  gitChangesService,
@@ -6239,6 +6457,10 @@ const sharedRouteDeps = {
6239
6457
  getPwaAppName,
6240
6458
  setPwaAppName,
6241
6459
  requestRelayShutdown,
6460
+ cancelRelayShutdown,
6461
+ relayShutdownStatePayload,
6462
+ hostSuspendService,
6463
+ collectHostActivity,
6242
6464
  markSharedViewerPresence,
6243
6465
  getSharedWatcherCount,
6244
6466
  statusEventService,
@@ -6253,6 +6475,7 @@ const sharedRouteDeps = {
6253
6475
  windowsAutostartService,
6254
6476
  windowsBootAutostartService,
6255
6477
  pushDispatchService,
6478
+ remoteRelayInbound,
6256
6479
  };
6257
6480
  registerMessagesRoutes(app, sharedRouteDeps);
6258
6481
  registerSessionsRoutes(app, sharedRouteDeps);
@@ -6265,6 +6488,12 @@ registerPushRoutes(app, {
6265
6488
  pushDispatchService,
6266
6489
  getPushVapidPublicKey: () => pushVapidKeys.publicKey,
6267
6490
  });
6491
+ registerRemoteRelayRoutes(app, {
6492
+ auth,
6493
+ registry: remoteRelayRegistry,
6494
+ pairing: remoteRelayPairing,
6495
+ dispatcher: remoteRelayDispatcher,
6496
+ });
6268
6497
  function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
6269
6498
  cliLastSeen = null;
6270
6499
  const wasOnline = cliOnline;
@@ -6619,6 +6848,9 @@ io.on('connection', (socket) => {
6619
6848
  });
6620
6849
  // Send current CLI status immediately on connect
6621
6850
  socket.emit('cli_status', { online: cliOnline });
6851
+ // Pending host suspend / relay restart banners must survive a reconnect.
6852
+ try { socket.emit('host_suspend_state', hostSuspendService.getState()); } catch {}
6853
+ try { socket.emit('relay_shutdown_state', relayShutdownStatePayload()); } catch {}
6622
6854
  // Visibility heartbeats for push suppression; also clears a stale
6623
6855
  // deviceVisible flag restored by connectionStateRecovery.
6624
6856
  activeDeviceTracker.registerSocket(socket);
@@ -6753,6 +6985,9 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
6753
6985
  runtimeShutdownStarted = true;
6754
6986
  console.log(`${runtimeLogPrefix()}Runtime shutdown started (${reason}, exitCode=${runtimeShutdownExitCode})`);
6755
6987
  clearRuntimeTimers();
6988
+ // A queued host suspend is in-memory only; drop it loudly rather than let
6989
+ // the next runtime forget about it in silence.
6990
+ try { hostSuspendService.dispose({ reason }); } catch {}
6756
6991
  try { updateCheckService?.stop(); } catch {}
6757
6992
  // Started here (flips the importer's closing flag synchronously) but awaited
6758
6993
  // below before the transports close: fire-and-forget disposal let a
@@ -6781,6 +7016,7 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
6781
7016
  sshTunnelManager.stop();
6782
7017
  cloudflaredTunnelManager.stop();
6783
7018
  previewHealthProbe.stop();
7019
+ try { remoteRelayRegistry.stopHealthLoop(); } catch {}
6784
7020
  // Previews are in-memory by design: a restart is the only implicit cleanup,
6785
7021
  // so every public link dies with the process.
6786
7022
  previewRegistry.clear();
@@ -6913,6 +7149,8 @@ httpServer.listen(config.port, listenHost, () => {
6913
7149
  // Start SSH tunnel after server is listening
6914
7150
  sshTunnelManager.start();
6915
7151
  cloudflaredTunnelManager.start();
7152
+ // Status, version and renames of paired relays; a first pass right away.
7153
+ remoteRelayRegistry.startHealthLoop();
6916
7154
  // A preview-lane failure must never take the relay with it: the listener is
6917
7155
  // optional infrastructure, so a bind error is logged and the relay carries on
6918
7156
  // without previews.
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+
3
+ import { execFile } from 'node:child_process';
4
+
5
+ /**
6
+ * Open GitHub Actions runs for a set of workspace roots, via the `gh` CLI the
7
+ * host already has (same credentials the agents use). Nothing in the relay
8
+ * models CI, so this is a best-effort outside view: a repo whose runs cannot
9
+ * be read counts as "unknown" and blocks for a grace period, after which it is
10
+ * ignored rather than keeping the host awake forever over a GitHub outage.
11
+ */
12
+
13
+ const DEFAULT_CACHE_MS = 20 * 1000;
14
+ const DEFAULT_UNKNOWN_GRACE_MS = 15 * 60 * 1000;
15
+ const DEFAULT_TIMEOUT_MS = 20 * 1000;
16
+ const RUN_LIST_LIMIT = 30;
17
+
18
+ const OPEN_RUN_STATUSES = new Set(['queued', 'in_progress', 'waiting', 'requested', 'pending']);
19
+
20
+ /** `git@github.com:o/r.git`, `https://github.com/o/r`, `ssh://git@github.com/o/r.git` → `o/r`. */
21
+ export function parseGitHubRepoSlug(remoteUrl) {
22
+ const text = String(remoteUrl || '').trim();
23
+ if (!text) return null;
24
+ const match = text.match(/github\.com[:/]+([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
25
+ if (!match) return null;
26
+ return `${match[1]}/${match[2]}`;
27
+ }
28
+
29
+ export function isOpenCiRunStatus(status) {
30
+ const value = String(status || '').trim().toLowerCase();
31
+ if (!value) return false;
32
+ return OPEN_RUN_STATUSES.has(value) || value !== 'completed';
33
+ }
34
+
35
+ function execText(execFileImpl, file, args, { cwd = undefined, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
36
+ return new Promise((resolve, reject) => {
37
+ let settled = false;
38
+ try {
39
+ execFileImpl(file, args, { cwd, timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
40
+ if (settled) return;
41
+ settled = true;
42
+ if (error) {
43
+ const detail = String(stderr || error.message || '').trim().split('\n')[0] || 'command failed';
44
+ reject(new Error(detail.slice(0, 200)));
45
+ return;
46
+ }
47
+ resolve(String(stdout || ''));
48
+ });
49
+ } catch (error) {
50
+ if (settled) return;
51
+ settled = true;
52
+ reject(error);
53
+ }
54
+ });
55
+ }
56
+
57
+ export function createGitHubCiActivityService({
58
+ execFileImpl = execFile,
59
+ now = () => Date.now(),
60
+ cacheMs = DEFAULT_CACHE_MS,
61
+ unknownGraceMs = DEFAULT_UNKNOWN_GRACE_MS,
62
+ timeoutMs = DEFAULT_TIMEOUT_MS,
63
+ logger = console,
64
+ } = {}) {
65
+ // root → { slug: string|null, at }
66
+ const slugByRoot = new Map();
67
+ // slug → { runs, error, at, unknownSince, inFlight }
68
+ const runsBySlug = new Map();
69
+
70
+ async function resolveSlug(root) {
71
+ const key = String(root || '').trim();
72
+ if (!key) return null;
73
+ const cached = slugByRoot.get(key);
74
+ if (cached && cached.at !== null && now() - cached.at < 10 * cacheMs) return cached.slug;
75
+ let slug = null;
76
+ try {
77
+ const url = await execText(execFileImpl, 'git', ['-C', key, 'remote', 'get-url', 'origin'], { timeoutMs });
78
+ slug = parseGitHubRepoSlug(url);
79
+ } catch {
80
+ slug = null;
81
+ }
82
+ slugByRoot.set(key, { slug, at: now() });
83
+ return slug;
84
+ }
85
+
86
+ async function fetchRuns(slug) {
87
+ const entry = runsBySlug.get(slug) || { runs: [], error: null, at: null, unknownSince: null, inFlight: null };
88
+ runsBySlug.set(slug, entry);
89
+ if (entry.inFlight) return entry.inFlight;
90
+ if (entry.at !== null && now() - entry.at < cacheMs) return entry;
91
+ entry.inFlight = (async () => {
92
+ try {
93
+ const out = await execText(execFileImpl, 'gh', [
94
+ 'run', 'list', '-R', slug, '--limit', String(RUN_LIST_LIMIT),
95
+ '--json', 'status,name,headBranch,databaseId,event',
96
+ ], { timeoutMs });
97
+ const parsed = JSON.parse(out || '[]');
98
+ entry.runs = (Array.isArray(parsed) ? parsed : [])
99
+ .filter((run) => run && isOpenCiRunStatus(run.status))
100
+ .map((run) => ({
101
+ id: run.databaseId ?? null,
102
+ name: String(run.name || 'workflow').slice(0, 120),
103
+ headBranch: String(run.headBranch || '').slice(0, 160),
104
+ status: String(run.status || '').slice(0, 40),
105
+ event: String(run.event || '').slice(0, 40),
106
+ }));
107
+ entry.error = null;
108
+ entry.unknownSince = null;
109
+ } catch (error) {
110
+ entry.error = String(error?.message || error || 'gh failed').slice(0, 200);
111
+ if (entry.unknownSince === null) entry.unknownSince = now();
112
+ try { logger.warn?.(`[host-suspend] CI state for ${slug} unknown: ${entry.error}`); } catch {}
113
+ } finally {
114
+ entry.at = now();
115
+ entry.inFlight = null;
116
+ }
117
+ return entry;
118
+ })();
119
+ return entry.inFlight;
120
+ }
121
+
122
+ /**
123
+ * @param {string[]} workspaceRoots
124
+ * @returns {Promise<{ repos: Array<{ repo, runs }>, unknown: Array<{ repo, error, since, ignored }>, blockers: Array<object> }>}
125
+ */
126
+ async function describeRuns(workspaceRoots = []) {
127
+ const roots = [...new Set((Array.isArray(workspaceRoots) ? workspaceRoots : []).map((r) => String(r || '').trim()).filter(Boolean))];
128
+ const slugs = new Set();
129
+ for (const root of roots) {
130
+ const slug = await resolveSlug(root);
131
+ if (slug) slugs.add(slug);
132
+ }
133
+ const repos = [];
134
+ const unknown = [];
135
+ const blockers = [];
136
+ for (const slug of slugs) {
137
+ const entry = await fetchRuns(slug);
138
+ if (entry.error) {
139
+ const since = entry.unknownSince === null ? now() : entry.unknownSince;
140
+ const ignored = now() - since >= unknownGraceMs;
141
+ unknown.push({ repo: slug, error: entry.error, since: new Date(since).toISOString(), ignored });
142
+ if (!ignored) {
143
+ blockers.push({ kind: 'ci-unknown', detail: `CI state of ${slug} unknown (${entry.error})`, title: slug });
144
+ }
145
+ continue;
146
+ }
147
+ if (entry.runs.length) {
148
+ repos.push({ repo: slug, runs: entry.runs.map((r) => ({ ...r })) });
149
+ const what = entry.runs.slice(0, 4).map((r) => `${r.name} on ${r.headBranch || '?'} (${r.status})`).join(', ');
150
+ blockers.push({ kind: 'ci', title: slug, count: entry.runs.length, detail: what });
151
+ }
152
+ }
153
+ return { repos, unknown, blockers };
154
+ }
155
+
156
+ /**
157
+ * Last known picture without touching git/gh — for synchronous collectors
158
+ * that kick describeRuns() off in the background. A root or repo that has
159
+ * never been resolved yet reports a `ci-checking` blocker so the host does
160
+ * not sleep during the very first lookup.
161
+ */
162
+ function snapshot(workspaceRoots = []) {
163
+ const blockers = [];
164
+ const repos = [];
165
+ const unknown = [];
166
+ const slugs = new Set();
167
+ let checking = 0;
168
+ // Several busy conversations usually share one workspace; count it once.
169
+ const roots = new Set((Array.isArray(workspaceRoots) ? workspaceRoots : []).map((root) => String(root || '').trim()));
170
+ for (const key of roots) {
171
+ if (!key) continue;
172
+ const cached = slugByRoot.get(key);
173
+ if (!cached) { checking += 1; continue; }
174
+ if (cached.slug) slugs.add(cached.slug);
175
+ }
176
+ for (const slug of slugs) {
177
+ const entry = runsBySlug.get(slug);
178
+ if (!entry || entry.at === null) { checking += 1; continue; }
179
+ if (entry.error) {
180
+ const since = entry.unknownSince === null ? now() : entry.unknownSince;
181
+ const ignored = now() - since >= unknownGraceMs;
182
+ unknown.push({ repo: slug, error: entry.error, since: new Date(since).toISOString(), ignored });
183
+ if (!ignored) blockers.push({ kind: 'ci-unknown', title: slug, detail: `CI state of ${slug} unknown (${entry.error})` });
184
+ continue;
185
+ }
186
+ if (entry.runs.length) {
187
+ repos.push({ repo: slug, runs: entry.runs.map((r) => ({ ...r })) });
188
+ const what = entry.runs.slice(0, 4).map((r) => `${r.name} on ${r.headBranch || '?'} (${r.status})`).join(', ');
189
+ blockers.push({ kind: 'ci', title: slug, count: entry.runs.length, detail: what });
190
+ }
191
+ }
192
+ if (checking > 0) {
193
+ blockers.push({ kind: 'ci-checking', count: checking, detail: `Checking CI state of ${checking} workspace${checking === 1 ? '' : 's'}` });
194
+ }
195
+ return { repos, unknown, blockers };
196
+ }
197
+
198
+ return { describeRuns, snapshot, parseGitHubRepoSlug };
199
+ }
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ import { spawn } from 'node:child_process';
4
+
5
+ /**
6
+ * The actual suspend-to-RAM call. Windows only: `SetSuspendState 0,0,0` puts
7
+ * the box into S3 (hibernation is not enabled on the host, so this never
8
+ * hibernates). Detached so the relay does not wait on it.
9
+ *
10
+ * `OAR_HOST_SUSPEND_DRY_RUN=1` logs instead of sleeping the machine, which is
11
+ * how the deferred-suspend flow gets exercised on a dev box.
12
+ */
13
+ export function runHostSuspendToRam({
14
+ platform = process.platform,
15
+ spawnImpl = spawn,
16
+ dryRun = /^(1|true|yes|on)$/i.test(String(process.env.OAR_HOST_SUSPEND_DRY_RUN || '')),
17
+ logger = console,
18
+ } = {}) {
19
+ if (platform !== 'win32') {
20
+ return { ok: false, statusCode: 501, error: 'Host suspend is only supported on Windows' };
21
+ }
22
+ const command = 'rundll32.exe powrprof.dll,SetSuspendState 0,0,0';
23
+ if (dryRun) {
24
+ try { logger.log?.(`[host-suspend] DRY RUN — would run: ${command}`); } catch {}
25
+ return { ok: true, command, dryRun: true };
26
+ }
27
+ try {
28
+ const child = spawnImpl('rundll32.exe', ['powrprof.dll,SetSuspendState', '0,0,0'], {
29
+ detached: true,
30
+ stdio: 'ignore',
31
+ windowsHide: true,
32
+ });
33
+ child.unref?.();
34
+ return { ok: true, command };
35
+ } catch (error) {
36
+ return { ok: false, statusCode: 500, error: error?.message || 'Failed to launch host suspend command' };
37
+ }
38
+ }