@oar-sh/oar 0.9.0

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 (293) hide show
  1. package/.github/extensions/web-relay/README.md +190 -0
  2. package/.github/extensions/web-relay/extension.mjs +972 -0
  3. package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
  4. package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
  5. package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
  6. package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
  7. package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
  8. package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
  9. package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
  10. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
  11. package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
  12. package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
  13. package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
  14. package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
  15. package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
  16. package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
  17. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
  18. package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
  19. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
  20. package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
  21. package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
  22. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
  23. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
  24. package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
  25. package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
  26. package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
  27. package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
  28. package/CHANGELOG.md +25 -0
  29. package/LICENSE +21 -0
  30. package/README.md +717 -0
  31. package/bin/bat/oar.bat +46 -0
  32. package/bin/oar.js +665 -0
  33. package/package.json +68 -0
  34. package/server/README.md +1379 -0
  35. package/server/claude-worker/claude-attachments.mjs +134 -0
  36. package/server/claude-worker/claude-preview-tool.mjs +57 -0
  37. package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
  38. package/server/claude-worker/claude-session-process.mjs +2391 -0
  39. package/server/claude-worker/claude-session-test-harness.mjs +236 -0
  40. package/server/claude-worker/claude-session-worker.mjs +186 -0
  41. package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
  42. package/server/claude-worker/claude-turn-publisher.mjs +291 -0
  43. package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
  44. package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
  45. package/server/claude-worker/workflow-progress-digest.mjs +269 -0
  46. package/server/copilot-sdk-runtime.mjs +126 -0
  47. package/server/copilot-worker/copilot-attachments.mjs +97 -0
  48. package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
  49. package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
  50. package/server/copilot-worker/copilot-plan-board.mjs +110 -0
  51. package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
  52. package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
  53. package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
  54. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
  55. package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
  56. package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
  57. package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
  58. package/server/copilot-worker/fixtures/abort-turn.json +588 -0
  59. package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
  60. package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
  61. package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
  62. package/server/copilot-worker/fixtures/happy-turn.json +566 -0
  63. package/server/copilot-worker/fixtures/quota-turn.json +647 -0
  64. package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
  65. package/server/copilot-worker/fixtures/resume-turn.json +700 -0
  66. package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
  67. package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
  68. package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
  69. package/server/cursor-worker/cursor-attachments.mjs +88 -0
  70. package/server/cursor-worker/cursor-context-usage.mjs +85 -0
  71. package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
  72. package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
  73. package/server/cursor-worker/cursor-session-worker.mjs +145 -0
  74. package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
  75. package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
  76. package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
  77. package/server/db-schema.mjs +932 -0
  78. package/server/features.mjs +106 -0
  79. package/server/grok-worker/acp-client.mjs +434 -0
  80. package/server/grok-worker/acp-host-services.mjs +277 -0
  81. package/server/grok-worker/grok-context-usage.mjs +77 -0
  82. package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
  83. package/server/grok-worker/grok-session-worker.mjs +145 -0
  84. package/server/grok-worker/grok-turn-runner.mjs +523 -0
  85. package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
  86. package/server/migrations/0001-add-structured-answer.mjs +80 -0
  87. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
  88. package/server/migrations/0003-push-subscriptions.mjs +87 -0
  89. package/server/public/app/action-confirmations.js +267 -0
  90. package/server/public/app/activity-replay-state.mjs +149 -0
  91. package/server/public/app/api-client.js +1082 -0
  92. package/server/public/app/ask-user-view.js +545 -0
  93. package/server/public/app/attachments-view.js +2112 -0
  94. package/server/public/app/auto-compact-window-options.mjs +59 -0
  95. package/server/public/app/background-tasks-view.mjs +494 -0
  96. package/server/public/app/bootstrap.js +4347 -0
  97. package/server/public/app/claude-auth-ui.js +394 -0
  98. package/server/public/app/claude-thinking-options.mjs +38 -0
  99. package/server/public/app/cli-install-ui.js +455 -0
  100. package/server/public/app/code-copy.mjs +85 -0
  101. package/server/public/app/composer-attachment-cache.mjs +151 -0
  102. package/server/public/app/composer-control-state.mjs +107 -0
  103. package/server/public/app/composer-paste.mjs +132 -0
  104. package/server/public/app/context-tier-options.mjs +68 -0
  105. package/server/public/app/context-usage-view.mjs +338 -0
  106. package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
  107. package/server/public/app/conversation-preferences.mjs +91 -0
  108. package/server/public/app/conversation-provider-indicator.mjs +88 -0
  109. package/server/public/app/conversation-view.js +2898 -0
  110. package/server/public/app/cwd-menu-placement.mjs +85 -0
  111. package/server/public/app/cwd-picker.js +605 -0
  112. package/server/public/app/emoji-view.js +101 -0
  113. package/server/public/app/external-link-policy.mjs +80 -0
  114. package/server/public/app/file-preview-navigation.mjs +51 -0
  115. package/server/public/app/font-scaling.js +262 -0
  116. package/server/public/app/git-changes-view.js +339 -0
  117. package/server/public/app/git-diff-model.mjs +109 -0
  118. package/server/public/app/grok-auth-ui.js +387 -0
  119. package/server/public/app/image-downscale.mjs +174 -0
  120. package/server/public/app/in-flight-snapshot.mjs +40 -0
  121. package/server/public/app/infinite-loader.js +164 -0
  122. package/server/public/app/journal-view.js +1141 -0
  123. package/server/public/app/known-cwd-options.mjs +41 -0
  124. package/server/public/app/launch-cwd-path.mjs +23 -0
  125. package/server/public/app/live-message-dedupe.mjs +57 -0
  126. package/server/public/app/message-search-view.js +242 -0
  127. package/server/public/app/model-selector-options.mjs +31 -0
  128. package/server/public/app/model-vision-support.mjs +56 -0
  129. package/server/public/app/new-conversation-model-choice.mjs +67 -0
  130. package/server/public/app/plan-usage-view.mjs +307 -0
  131. package/server/public/app/preview-cards.mjs +273 -0
  132. package/server/public/app/preview-command.mjs +103 -0
  133. package/server/public/app/push-settings.js +370 -0
  134. package/server/public/app/pwa-install.js +456 -0
  135. package/server/public/app/question-schema-view.mjs +71 -0
  136. package/server/public/app/reasoning-effort-labels.mjs +42 -0
  137. package/server/public/app/relay-board-view.js +117 -0
  138. package/server/public/app/relay-dot-state.mjs +69 -0
  139. package/server/public/app/relay-error-ctas.mjs +52 -0
  140. package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
  141. package/server/public/app/relay-thoughts.mjs +98 -0
  142. package/server/public/app/relay-watchdog-policy.mjs +29 -0
  143. package/server/public/app/repo-browser-preferences.mjs +79 -0
  144. package/server/public/app/repo-browser-tree-state.mjs +97 -0
  145. package/server/public/app/router.js +657 -0
  146. package/server/public/app/selection-guard.mjs +93 -0
  147. package/server/public/app/settings-modal.js +1484 -0
  148. package/server/public/app/settings-tabs.js +129 -0
  149. package/server/public/app/slash-autocomplete.mjs +188 -0
  150. package/server/public/app/slash-commands.mjs +183 -0
  151. package/server/public/app/socket-handlers.js +703 -0
  152. package/server/public/app/status-store.mjs +242 -0
  153. package/server/public/app/status-view.mjs +315 -0
  154. package/server/public/app/store.js +1454 -0
  155. package/server/public/app/stream-state.mjs +73 -0
  156. package/server/public/app/streaming-dom-patch.mjs +23 -0
  157. package/server/public/app/sync-outbox.mjs +208 -0
  158. package/server/public/app/tap-actions.js +62 -0
  159. package/server/public/app/thread-order.mjs +54 -0
  160. package/server/public/app/tmux-inspector-view.js +422 -0
  161. package/server/public/app/transcript-separators.mjs +282 -0
  162. package/server/public/app-icon-192.png +0 -0
  163. package/server/public/app-icon-512.png +0 -0
  164. package/server/public/app-icon.svg +4 -0
  165. package/server/public/favicon.ico +0 -0
  166. package/server/public/index.html +5743 -0
  167. package/server/public/manifest.webmanifest +32 -0
  168. package/server/public/sw.js +307 -0
  169. package/server/relay-exit-codes.mjs +4 -0
  170. package/server/relay-self-restart.mjs +170 -0
  171. package/server/relay-tools.md +27 -0
  172. package/server/relay.mjs +1026 -0
  173. package/server/repositories/image-conversation-repository.mjs +317 -0
  174. package/server/repositories/message-repository.mjs +434 -0
  175. package/server/repositories/question-repository.mjs +161 -0
  176. package/server/repositories/session-repository.mjs +320 -0
  177. package/server/repositories/share-visibility-statements.mjs +24 -0
  178. package/server/routes/ask-user-routes.mjs +292 -0
  179. package/server/routes/cache-routes.mjs +44 -0
  180. package/server/routes/git-routes.mjs +92 -0
  181. package/server/routes/messages-routes-test-harness.mjs +111 -0
  182. package/server/routes/messages-routes.mjs +7107 -0
  183. package/server/routes/preview-routes.mjs +108 -0
  184. package/server/routes/push-routes.mjs +59 -0
  185. package/server/routes/relay-board-routes.mjs +268 -0
  186. package/server/routes/sessions-routes.mjs +6150 -0
  187. package/server/server-runtime.mjs +7463 -0
  188. package/server/server.js +24 -0
  189. package/server/services/ask-user-routing-service.mjs +117 -0
  190. package/server/services/cache-rebuild-service.mjs +236 -0
  191. package/server/services/claude-auth-service.mjs +578 -0
  192. package/server/services/claude-context-usage.mjs +240 -0
  193. package/server/services/claude-session-root-service.mjs +228 -0
  194. package/server/services/cli-install-service.mjs +1152 -0
  195. package/server/services/cli-process-runner.mjs +181 -0
  196. package/server/services/cloudflared-tunnel-service.mjs +358 -0
  197. package/server/services/context-snapshot-service.mjs +499 -0
  198. package/server/services/context-usage-view.mjs +160 -0
  199. package/server/services/conversation-preferences-service.mjs +208 -0
  200. package/server/services/cursor-dashboard-usage.mjs +217 -0
  201. package/server/services/cursor-session-root-service.mjs +62 -0
  202. package/server/services/delete-archive-service.mjs +154 -0
  203. package/server/services/drives-path-helpers.mjs +66 -0
  204. package/server/services/fixtures/claude-auth-stub.sh +62 -0
  205. package/server/services/fixtures/cli-install-stub.sh +75 -0
  206. package/server/services/fixtures/grok-stub.sh +86 -0
  207. package/server/services/generated-image-cleanup-service.mjs +114 -0
  208. package/server/services/git-changes-service.mjs +195 -0
  209. package/server/services/github-billing-usage.mjs +133 -0
  210. package/server/services/grok-auth-service.mjs +593 -0
  211. package/server/services/grok-billing-usage.mjs +120 -0
  212. package/server/services/image-context-service.mjs +106 -0
  213. package/server/services/image-operation-service.mjs +135 -0
  214. package/server/services/keyed-mutex-service.mjs +82 -0
  215. package/server/services/mime-sniffer.mjs +118 -0
  216. package/server/services/oar-cli-helpers.mjs +109 -0
  217. package/server/services/oar-state-migration-service.mjs +243 -0
  218. package/server/services/plan-usage-claude.mjs +387 -0
  219. package/server/services/plan-usage-contract.mjs +271 -0
  220. package/server/services/plan-usage-copilot.mjs +421 -0
  221. package/server/services/plan-usage-cursor.mjs +566 -0
  222. package/server/services/plan-usage-grok.mjs +436 -0
  223. package/server/services/plan-usage-service.mjs +487 -0
  224. package/server/services/preview-health-probe.mjs +82 -0
  225. package/server/services/preview-proxy-server.mjs +498 -0
  226. package/server/services/preview-registry-service.mjs +457 -0
  227. package/server/services/preview-static-handler.mjs +237 -0
  228. package/server/services/provider-model-selection.mjs +91 -0
  229. package/server/services/provider-reasoning-effort.mjs +94 -0
  230. package/server/services/push-active-device-service.mjs +57 -0
  231. package/server/services/push-dispatch-service.mjs +459 -0
  232. package/server/services/rate-limit-service.mjs +63 -0
  233. package/server/services/relay-bridge-owner-service.mjs +84 -0
  234. package/server/services/relay-cli-launcher-service.mjs +209 -0
  235. package/server/services/relay-prompt-sanitizer.mjs +137 -0
  236. package/server/services/relay-queue-gate-service.mjs +38 -0
  237. package/server/services/relay-restart-orchestrator-service.mjs +935 -0
  238. package/server/services/relay-singleton-guard.mjs +124 -0
  239. package/server/services/safe-served-content.mjs +57 -0
  240. package/server/services/sdk-session-import-service.mjs +271 -0
  241. package/server/services/sdk-session-sync-service.mjs +207 -0
  242. package/server/services/session-history-refresh-service.mjs +497 -0
  243. package/server/services/session-transcript-service.mjs +419 -0
  244. package/server/services/session-worker-launch-service.mjs +786 -0
  245. package/server/services/session-worker-process-service.mjs +319 -0
  246. package/server/services/session-worker-registry-service.mjs +194 -0
  247. package/server/services/session-worker-stop-service.mjs +141 -0
  248. package/server/services/session-worker-supervisor-service.mjs +898 -0
  249. package/server/services/session-worker-websocket-service.mjs +483 -0
  250. package/server/services/ssh-tunnel-manager-service.mjs +423 -0
  251. package/server/services/status-event-service.mjs +158 -0
  252. package/server/services/tmux-inspector-access-policy.mjs +77 -0
  253. package/server/services/tmux-inspector-socket-service.mjs +191 -0
  254. package/server/services/tmux-inspector-stream-service.mjs +552 -0
  255. package/server/services/tunnel-worker-path-guard.mjs +107 -0
  256. package/server/services/upload-sweep.mjs +60 -0
  257. package/server/services/usage-snapshot-helpers.mjs +209 -0
  258. package/server/services/windows-autostart-service.mjs +115 -0
  259. package/server/services/workspace-root-defaults-service.mjs +34 -0
  260. package/server/services/workspace-root-path-policy.mjs +196 -0
  261. package/server/services/workspace-root-relaunch-service.mjs +132 -0
  262. package/server/services/workspace-symlink-guard.mjs +54 -0
  263. package/server/tools/capture-proxy.mjs +173 -0
  264. package/server/tty-console-bootstrap.mjs +75 -0
  265. package/server/uploads/README.md +36 -0
  266. package/server/uploads/image_input.md +540 -0
  267. package/server/windows-terminal-launcher.mjs +29 -0
  268. package/server/workspace-root.mjs +87 -0
  269. package/shared/ask-user-bridge.mjs +131 -0
  270. package/shared/auto-compact-window.mjs +116 -0
  271. package/shared/background-task-timeout.mjs +52 -0
  272. package/shared/claude-thinking.mjs +101 -0
  273. package/shared/context-window-fallbacks.mjs +148 -0
  274. package/shared/control-poller.mjs +115 -0
  275. package/shared/empty-turn-completion.mjs +20 -0
  276. package/shared/model-catalog-freshness.mjs +11 -0
  277. package/shared/model-descriptors.mjs +118 -0
  278. package/shared/model-id.mjs +147 -0
  279. package/shared/model-refresh.mjs +24 -0
  280. package/shared/openai-reasoning.mjs +13 -0
  281. package/shared/plan-lines.mjs +13 -0
  282. package/shared/preview-instructions.mjs +77 -0
  283. package/shared/preview-tool-core.mjs +191 -0
  284. package/shared/provider-routing.mjs +29 -0
  285. package/shared/question-schema.mjs +315 -0
  286. package/shared/question-timeout.mjs +18 -0
  287. package/shared/stream-emit-gating.mjs +28 -0
  288. package/shared/subagent-run-id.mjs +17 -0
  289. package/shared/thought-cap.mjs +13 -0
  290. package/shared/tool-activity.mjs +98 -0
  291. package/shared/turn-ceiling.mjs +53 -0
  292. package/shared/worker-bootstrap.mjs +44 -0
  293. package/shared/worker-crash-guard.mjs +60 -0
@@ -0,0 +1,1026 @@
1
+ /**
2
+ * relay.mjs — standalone Copilot SDK relay
3
+ *
4
+ * Spawns its own Copilot CLI process, polls the web server for pending
5
+ * messages, processes each with sendAndWait(), and posts responses back.
6
+ * On Windows, foreground mode prefers a stable Windows Terminal window name so
7
+ * repeated launches reuse the same visible window instead of creating new ones.
8
+ *
9
+ * Usage:
10
+ * node relay.mjs # hidden CLI subprocess (default off Windows)
11
+ * node relay.mjs --foreground # visible terminal window
12
+ * node relay.mjs --hidden # force hidden stdio fallback
13
+ * node relay.mjs --token <newtoken> # override auth token
14
+ */
15
+
16
+ import { fileURLToPath } from 'url';
17
+ import { createConnection } from 'net';
18
+ import { randomUUID } from 'crypto';
19
+ import { spawn } from 'child_process';
20
+ import path from 'path';
21
+ import fs from 'fs';
22
+ import { resolveStartupWorkspaceRoot } from './workspace-root.mjs';
23
+ import { resolveInstalledCopilotPaths } from './copilot-sdk-runtime.mjs';
24
+ import { isLegacyRelayProviderType } from '../shared/provider-routing.mjs';
25
+ import {
26
+ buildWindowsTerminalForegroundArgs,
27
+ buildWindowsTerminalWindowName,
28
+ } from './windows-terminal-launcher.mjs';
29
+ import { isValidModelId, normalizeModelIdCandidate } from '../shared/model-id.mjs';
30
+ import {
31
+ extractModelDescriptors,
32
+ normalizeContextLimitTokens,
33
+ } from '../shared/model-descriptors.mjs';
34
+
35
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
+
37
+ // ─── Args ──────────────────────────────────────────────────────────────────────
38
+ const args = process.argv.slice(2);
39
+ const FORCE_HIDDEN = args.includes('--hidden') || args.includes('--stdio');
40
+ const FOREGROUND = !FORCE_HIDDEN && (args.includes('--foreground') || process.platform === 'win32');
41
+ const VERBOSE = args.includes('--quiet') ? false : true;
42
+ const tokenArgIdx = args.indexOf('--token');
43
+ const TOKEN_OVERRIDE = tokenArgIdx !== -1 ? args[tokenArgIdx + 1] : null;
44
+ const portArgIdx = args.indexOf('--port');
45
+ const PORT_OVERRIDE = portArgIdx !== -1 ? args[portArgIdx + 1] : null;
46
+
47
+ // ─── Config ────────────────────────────────────────────────────────────────────
48
+ const configPath = process.env.COPILOT_WEB_RELAY_CONFIG
49
+ ? path.resolve(String(process.env.COPILOT_WEB_RELAY_CONFIG))
50
+ : path.join(__dirname, 'config.json');
51
+ function readConfig(filePath) {
52
+ try {
53
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
54
+ } catch {
55
+ return {};
56
+ }
57
+ }
58
+
59
+ function parsePort(value, fallback = 3333) {
60
+ const parsed = Number.parseInt(String(value || '').trim(), 10);
61
+ return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
62
+ }
63
+
64
+ const config = readConfig(configPath);
65
+ const SERVER_PORT = parsePort(PORT_OVERRIDE, parsePort(config.port, 3333));
66
+ const SERVER_URL = `http://localhost:${SERVER_PORT}`;
67
+ const TOKEN = TOKEN_OVERRIDE || config.authToken || '';
68
+ const RUNTIME_SESSION_POLICY = 'conversation-bound';
69
+ const launchWorkspaceRoot = resolveStartupWorkspaceRoot(__dirname);
70
+
71
+ // ─── Copilot path auto-detection ─────────────────────────────────────────────
72
+ let installedCopilotPaths;
73
+ try {
74
+ installedCopilotPaths = resolveInstalledCopilotPaths({ config });
75
+ } catch (error) {
76
+ console.error(`[relay] ERROR ${error.message}`);
77
+ process.exit(1);
78
+ }
79
+ const { sdkPath: SDK_PATH, cliPath: CLI_PATH } = installedCopilotPaths;
80
+ const NODE_PATH = process.env.COPILOT_WEB_RELAY_NODE || process.execPath;
81
+ const CLI_PORT = config.cliPort || 4445;
82
+ const foregroundWindowName = buildWindowsTerminalWindowName(launchWorkspaceRoot);
83
+
84
+ function ts() {
85
+ const now = new Date();
86
+ return [
87
+ now.getUTCFullYear(),
88
+ String(now.getUTCMonth() + 1).padStart(2, '0'),
89
+ String(now.getUTCDate()).padStart(2, '0'),
90
+ String(now.getUTCHours()).padStart(2, '0'),
91
+ String(now.getUTCMinutes()).padStart(2, '0'),
92
+ String(now.getUTCSeconds()).padStart(2, '0'),
93
+ ].join('-');
94
+ }
95
+ function log(...a) { console.log(`[relay ${ts()}]`, ...a); }
96
+ function err(...a) { console.error(`[relay ${ts()}] ERROR`, ...a); }
97
+ function vlog(...a) { if (VERBOSE) log(...a); }
98
+
99
+ function logTextBlock(title, text) {
100
+ if (!VERBOSE) return;
101
+ const divider = '-'.repeat(26);
102
+ console.log(`[relay ${ts()}] ${divider} ${title} ${divider}`);
103
+ console.log(String(text || ''));
104
+ console.log(`[relay ${ts()}] ${'-'.repeat(60)}`);
105
+ }
106
+
107
+ function escapeXmlText(value) {
108
+ return String(value || '')
109
+ .replace(/&/g, '&amp;')
110
+ .replace(/</g, '&lt;')
111
+ .replace(/>/g, '&gt;')
112
+ .replace(/"/g, '&quot;')
113
+ .replace(/'/g, '&apos;');
114
+ }
115
+
116
+ function buildAttachmentSystemReminder(attachments) {
117
+ if (!Array.isArray(attachments) || !attachments.length) return '';
118
+ const lines = [
119
+ '<system_reminder>',
120
+ 'Attached files:',
121
+ ];
122
+ for (let idx = 0; idx < attachments.length; idx++) {
123
+ const att = attachments[idx];
124
+ if (!att || typeof att !== 'object') continue;
125
+ const name = escapeXmlText(String(att.name || '').trim() || `attachment-${idx + 1}`);
126
+ const mime = escapeXmlText(String(att.type || '').trim() || 'application/octet-stream');
127
+ const rawSize = Number(att.size);
128
+ const size = Number.isFinite(rawSize) && rawSize >= 0 ? Math.round(rawSize) : 0;
129
+ const filePath = String(att.path || '').trim();
130
+
131
+ lines.push(`- File ${idx + 1}: "${name}"`);
132
+ if (filePath) lines.push(` Path: ${escapeXmlText(filePath)}`);
133
+ lines.push(` MIME: ${mime}`);
134
+ lines.push(` Size: ${size} bytes`);
135
+ }
136
+ lines.push('</system_reminder>');
137
+ return lines.join('\n');
138
+ }
139
+
140
+ function redactAttachmentPathsFromPrompt(text) {
141
+ return String(text || '').replace(/^(\s*Path:\s*).+$/gim, '$1[REDACTED_PATH]');
142
+ }
143
+
144
+ function buildPrompt(msg) {
145
+ const text = String(msg?.text || '').trim();
146
+ const attachmentPromptContext = String(msg?.attachmentPromptContext || '').trim();
147
+ const attachments = Array.isArray(msg?.attachments) ? msg.attachments : [];
148
+ if (!attachments.length) return text;
149
+ const systemReminderBlock = attachmentPromptContext || buildAttachmentSystemReminder(attachments);
150
+ if (text && systemReminderBlock) return `${text}\n\n${systemReminderBlock}`;
151
+ return text || systemReminderBlock;
152
+ }
153
+
154
+ function extractBase64FromDataUrl(dataUrl) {
155
+ const value = String(dataUrl || '').trim();
156
+ if (!value.startsWith('data:')) return '';
157
+ const commaIdx = value.indexOf(',');
158
+ if (commaIdx < 0) return '';
159
+ const metadata = value.slice(0, commaIdx).toLowerCase();
160
+ if (!metadata.includes(';base64')) return '';
161
+ return value.slice(commaIdx + 1).trim();
162
+ }
163
+
164
+ function buildSdkAttachments(msg) {
165
+ const attachments = Array.isArray(msg?.attachments) ? msg.attachments : [];
166
+ const out = [];
167
+ for (const att of attachments) {
168
+ if (!att || typeof att !== 'object') continue;
169
+ const mimeType = String(att.type || '').trim().toLowerCase();
170
+ if (!mimeType.startsWith('image/')) continue;
171
+ const displayName = String(att.name || '').trim() || 'image';
172
+
173
+ const filePath = String(att.path || '').trim();
174
+ if (filePath) {
175
+ out.push({
176
+ type: 'file',
177
+ path: filePath,
178
+ displayName,
179
+ });
180
+ continue;
181
+ }
182
+
183
+ const data = extractBase64FromDataUrl(att.dataUrl);
184
+ if (!data) continue;
185
+ out.push({
186
+ type: 'blob',
187
+ data,
188
+ mimeType,
189
+ displayName,
190
+ });
191
+ }
192
+ return out;
193
+ }
194
+
195
+ function collectTextCandidates(value, out = []) {
196
+ if (!value) return out;
197
+ if (typeof value === 'string') {
198
+ const text = value.trim();
199
+ if (text) out.push(text);
200
+ return out;
201
+ }
202
+ if (Array.isArray(value)) {
203
+ for (const item of value) collectTextCandidates(item, out);
204
+ return out;
205
+ }
206
+ if (typeof value === 'object') {
207
+ collectTextCandidates(value.text, out);
208
+ collectTextCandidates(value.content, out);
209
+ collectTextCandidates(value.output_text, out);
210
+ collectTextCandidates(value.outputText, out);
211
+ collectTextCandidates(value.summary, out);
212
+ collectTextCandidates(value.message, out);
213
+ collectTextCandidates(value.result, out);
214
+ collectTextCandidates(value.response, out);
215
+ collectTextCandidates(value.output, out);
216
+ collectTextCandidates(value.answer, out);
217
+ collectTextCandidates(value.finalText, out);
218
+ collectTextCandidates(value.final_text, out);
219
+ }
220
+ return out;
221
+ }
222
+
223
+ /**
224
+ * Deep-scans a response envelope for a specific named tool call and returns a field from its input.
225
+ * Used to extract `task_complete({ summary })` from the response when the agent ends via tool.
226
+ */
227
+ function extractToolCallInput(value, toolName, field, depth = 0) {
228
+ if (!value || typeof value !== 'object' || depth > 12) return null;
229
+ if (Array.isArray(value)) {
230
+ for (const item of value) {
231
+ const found = extractToolCallInput(item, toolName, field, depth + 1);
232
+ if (found) return found;
233
+ }
234
+ return null;
235
+ }
236
+ // Check if this node is the target tool call
237
+ const name = value.name || value.tool || value.function_name || value.toolName || value.tool_name;
238
+ if (name === toolName) {
239
+ const input = value.input || value.arguments || value.params || value.args || {};
240
+ if (input && typeof input === 'object') {
241
+ const text = input[field];
242
+ if (text && typeof text === 'string' && text.trim()) return text.trim();
243
+ }
244
+ }
245
+ // Recurse into common envelope container keys only (avoids traversing arbitrary tool inputs)
246
+ const CONTAINER_KEYS = ['data', 'output', 'content', 'tool_calls', 'calls', 'items',
247
+ 'results', 'steps', 'turns', 'messages', 'events'];
248
+ for (const key of CONTAINER_KEYS) {
249
+ const child = value[key];
250
+ if (child !== undefined && child !== null) {
251
+ const found = extractToolCallInput(child, toolName, field, depth + 1);
252
+ if (found) return found;
253
+ }
254
+ }
255
+ return null;
256
+ }
257
+
258
+ function extractFinalText(finalEvent) {
259
+ // task_complete({ summary }) is explicitly the agent's closing message — highest priority.
260
+ const taskSummary = extractToolCallInput(finalEvent, 'task_complete', 'summary');
261
+ if (taskSummary) return taskSummary;
262
+
263
+ const candidates = [];
264
+ collectTextCandidates(finalEvent?.data?.content, candidates);
265
+ collectTextCandidates(finalEvent?.data?.text, candidates);
266
+ collectTextCandidates(finalEvent?.data?.output_text, candidates);
267
+ collectTextCandidates(finalEvent?.data?.outputText, candidates);
268
+ collectTextCandidates(finalEvent?.data?.summary, candidates);
269
+ collectTextCandidates(finalEvent?.data?.message, candidates);
270
+ collectTextCandidates(finalEvent?.data?.result, candidates);
271
+ collectTextCandidates(finalEvent?.data?.response, candidates);
272
+ collectTextCandidates(finalEvent?.data?.output, candidates);
273
+ collectTextCandidates(finalEvent?.data?.answer, candidates);
274
+ collectTextCandidates(finalEvent?.data?.finalText, candidates);
275
+ collectTextCandidates(finalEvent?.data?.final_text, candidates);
276
+ collectTextCandidates(finalEvent?.content, candidates);
277
+ collectTextCandidates(finalEvent?.text, candidates);
278
+ collectTextCandidates(finalEvent?.output_text, candidates);
279
+ collectTextCandidates(finalEvent?.outputText, candidates);
280
+ collectTextCandidates(finalEvent?.summary, candidates);
281
+ collectTextCandidates(finalEvent?.message, candidates);
282
+ collectTextCandidates(finalEvent?.result, candidates);
283
+ collectTextCandidates(finalEvent?.response, candidates);
284
+ collectTextCandidates(finalEvent?.output, candidates);
285
+ collectTextCandidates(finalEvent?.answer, candidates);
286
+ collectTextCandidates(finalEvent?.finalText, candidates);
287
+ collectTextCandidates(finalEvent?.final_text, candidates);
288
+ return candidates.find((value) => typeof value === 'string' && value.trim()) || '';
289
+ }
290
+
291
+ function buildFallbackAssistantText(activityText = '') {
292
+ const hint = String(activityText || '').trim();
293
+ const parts = [
294
+ "I couldn't capture a direct assistant reply, but the turn completed.",
295
+ "This can happen when the answer is routed through a tool or sub-agent instead of the main text channel.",
296
+ ];
297
+ if (hint) parts.push(`Latest activity seen: ${hint}`);
298
+ else parts.push("Ask again for a final summary if you want a cleaner reply.");
299
+ return parts.join(' ');
300
+ }
301
+
302
+ async function apiJson(method, pathName, body = undefined, { auth = false, allow404 = false } = {}) {
303
+ const started = Date.now();
304
+ const headers = {};
305
+ if (auth) headers.Authorization = `Bearer ${TOKEN}`;
306
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
307
+
308
+ let response;
309
+ try {
310
+ response = await fetch(`${SERVER_URL}${pathName}`, {
311
+ method,
312
+ headers,
313
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
314
+ });
315
+ } catch (e) {
316
+ err(`HTTP ${method} ${pathName} failed after ${Date.now() - started}ms: ${e.message}`);
317
+ throw e;
318
+ }
319
+
320
+ const elapsed = Date.now() - started;
321
+ const isOk = response.ok || (allow404 && response.status === 404);
322
+ vlog(`HTTP ${method} ${pathName} -> ${response.status} in ${elapsed}ms`);
323
+
324
+ if (!isOk) {
325
+ const errText = await response.text().catch(() => '');
326
+ throw new Error(`HTTP ${response.status} ${pathName} ${errText}`.trim());
327
+ }
328
+
329
+ return response.json();
330
+ }
331
+
332
+ // ─── Session Map ───────────────────────────────────────────────────────────────
333
+ // Keyed by conversation+model so each model can maintain its own session state.
334
+ const sessions = new Map();
335
+
336
+ let client = null;
337
+ let clientReady = false;
338
+ let lastModelSnapshotMs = 0;
339
+ const MODEL_SNAPSHOT_MIN_INTERVAL_MS = 30_000;
340
+ const cachedModelMetadataById = new Map();
341
+
342
+ const MODEL_ALIAS_CANDIDATES = {
343
+ 'sonnet-4.6': ['sonnet-4.6', 'claude-sonnet-4.6', 'claude-sonnet-4-6', 'anthropic/claude-sonnet-4.6'],
344
+ 'haiku-4.5': ['haiku-4.5', 'claude-haiku-4.5', 'claude-haiku-4-5', 'anthropic/claude-haiku-4.5'],
345
+ 'sonnet-4.5': ['sonnet-4.5', 'claude-sonnet-4.5', 'claude-sonnet-4-5', 'anthropic/claude-sonnet-4.5'],
346
+ 'gpt-5.4': ['gpt-5.4', 'openai/gpt-5.4'],
347
+ 'codex-5.3': ['codex-5.3', 'codex-5', 'gpt-codex-5', 'openai/codex-5.3'],
348
+ 'gpt-5.4-mini': ['gpt-5.4-mini', 'gpt-5-mini', 'openai/gpt-5.4-mini'],
349
+ };
350
+
351
+ // ─── Foreground CLI Window ─────────────────────────────────────────────────────
352
+ /**
353
+ * Spawns the Copilot CLI in a visible terminal window using TCP server mode.
354
+ * Returns { tcpConnectionToken } so the relay can connect via cliUrl.
355
+ */
356
+ function waitForProcessSpawn(command, commandArgs, options) {
357
+ return new Promise((resolve, reject) => {
358
+ const child = spawn(command, commandArgs, options);
359
+ let settled = false;
360
+ child.once('spawn', () => {
361
+ settled = true;
362
+ resolve(child);
363
+ });
364
+ child.once('error', (error) => {
365
+ if (settled) return;
366
+ reject(error);
367
+ });
368
+ });
369
+ }
370
+
371
+ async function spawnForegroundCliViaPowerShell(tcpConnectionToken, port) {
372
+ const nodeArgs = [
373
+ CLI_PATH,
374
+ '--headless',
375
+ '--no-auto-update',
376
+ '--log-level', 'debug',
377
+ '--port', String(port),
378
+ ].join(' ');
379
+
380
+ const psCommand = `$env:COPILOT_CONNECTION_TOKEN='${tcpConnectionToken}'; & '${NODE_PATH}' ${nodeArgs}`;
381
+ const child = await waitForProcessSpawn('powershell.exe', [
382
+ '-NoExit',
383
+ '-Command',
384
+ psCommand,
385
+ ], {
386
+ detached: true,
387
+ stdio: 'ignore',
388
+ windowStyle: 'Normal',
389
+ });
390
+ child.unref?.();
391
+ return child;
392
+ }
393
+
394
+ async function spawnForegroundCli() {
395
+ const tcpConnectionToken = randomUUID();
396
+ const port = CLI_PORT;
397
+
398
+ if (process.platform === 'win32') {
399
+ const wtArgs = buildWindowsTerminalForegroundArgs({
400
+ workspaceRoot: launchWorkspaceRoot,
401
+ windowName: foregroundWindowName,
402
+ title: 'Copilot Relay',
403
+ commandPath: NODE_PATH,
404
+ commandArgs: [
405
+ CLI_PATH,
406
+ '--headless',
407
+ '--no-auto-update',
408
+ '--log-level', 'debug',
409
+ '--port', String(port),
410
+ ],
411
+ });
412
+
413
+ try {
414
+ log(`Spawning visible CLI in Windows Terminal window ${foregroundWindowName} on port ${port}...`);
415
+ const child = await waitForProcessSpawn('wt.exe', wtArgs, {
416
+ cwd: launchWorkspaceRoot,
417
+ env: {
418
+ ...process.env,
419
+ COPILOT_WORKSPACE_ROOT: launchWorkspaceRoot,
420
+ COPILOT_CONNECTION_TOKEN: tcpConnectionToken,
421
+ },
422
+ detached: true,
423
+ stdio: 'ignore',
424
+ windowsHide: false,
425
+ });
426
+ child.unref?.();
427
+ } catch (error) {
428
+ log(`Windows Terminal launch failed (${error?.message || String(error)}); falling back to PowerShell window`);
429
+ await spawnForegroundCliViaPowerShell(tcpConnectionToken, port);
430
+ }
431
+ } else {
432
+ throw new Error(
433
+ 'Foreground CLI spawn is currently Windows-only in standalone relay mode. '
434
+ + 'Use hidden mode (default) on Linux/macOS.'
435
+ );
436
+ }
437
+
438
+ // Wait up to 20s for the CLI to open its TCP port
439
+ log(`Waiting for CLI to listen on port ${port}...`);
440
+ const ready = await waitForPort(port, 20_000);
441
+ if (!ready) throw new Error(`CLI did not open port ${port} within 20s`);
442
+
443
+ log(`CLI is listening on port ${port} ✓`);
444
+ return { tcpConnectionToken, port };
445
+ }
446
+
447
+ function waitForPort(port, timeoutMs) {
448
+ return new Promise(resolve => {
449
+ const deadline = Date.now() + timeoutMs;
450
+ function attempt() {
451
+ const sock = createConnection({ port, host: '127.0.0.1' });
452
+ sock.on('connect', () => { sock.destroy(); resolve(true); });
453
+ sock.on('error', () => {
454
+ sock.destroy();
455
+ if (Date.now() >= deadline) return resolve(false);
456
+ setTimeout(attempt, 500);
457
+ });
458
+ }
459
+ attempt();
460
+ });
461
+ }
462
+
463
+ export function buildCopilotClientOptions({
464
+ foreground = false,
465
+ tcpInfo = null,
466
+ cliPath = CLI_PATH,
467
+ launchRoot = launchWorkspaceRoot,
468
+ } = {}) {
469
+ if (foreground && tcpInfo) {
470
+ return {
471
+ connection: {
472
+ kind: 'uri',
473
+ url: `localhost:${tcpInfo.port}`,
474
+ connectionToken: tcpInfo.tcpConnectionToken,
475
+ },
476
+ };
477
+ }
478
+
479
+ return {
480
+ connection: {
481
+ kind: 'stdio',
482
+ path: cliPath,
483
+ },
484
+ useLoggedInUser: true,
485
+ logLevel: 'debug',
486
+ workingDirectory: launchRoot,
487
+ };
488
+ }
489
+
490
+ // ─── Client Init ──────────────────────────────────────────────────────────────
491
+ async function initClient(CopilotClient) {
492
+ log('Initialising Copilot SDK client...');
493
+
494
+ let clientOptions;
495
+
496
+ if (FOREGROUND) {
497
+ let tcpInfo;
498
+ try {
499
+ tcpInfo = await spawnForegroundCli();
500
+ } catch (e) {
501
+ err(`Foreground CLI failed (${e.message}), falling back to hidden mode`);
502
+ }
503
+
504
+ if (tcpInfo) {
505
+ clientOptions = buildCopilotClientOptions({ foreground: true, tcpInfo });
506
+ log(`Connecting to foreground CLI at localhost:${tcpInfo.port}`);
507
+ }
508
+ }
509
+
510
+ // Default: hidden subprocess via stdio
511
+ if (!clientOptions) {
512
+ clientOptions = buildCopilotClientOptions();
513
+ if (FOREGROUND) log('Using hidden CLI (foreground spawn failed)');
514
+ }
515
+
516
+ vlog('Copilot client options:', {
517
+ mode: clientOptions.connection?.kind === 'uri' ? 'foreground-tcp' : 'hidden-stdio',
518
+ logLevel: clientOptions.logLevel || 'n/a',
519
+ cwd: clientOptions.workingDirectory || launchWorkspaceRoot,
520
+ });
521
+
522
+ client = new CopilotClient(clientOptions);
523
+ await client.start();
524
+ clientReady = true;
525
+ log(`Copilot SDK client ready ✓ (mode: ${FOREGROUND && clientOptions.connection?.kind === 'uri' ? 'foreground TCP' : 'hidden stdio'})`);
526
+ }
527
+
528
+ function sessionKey(runtimeSessionId, convId) {
529
+ const runtime = String(runtimeSessionId || '').trim();
530
+ if (runtime) return runtime;
531
+ const conversation = String(convId || '').trim();
532
+ if (!conversation) return '__fallback__';
533
+ return `conv:${conversation}`;
534
+ }
535
+
536
+ async function getOrCreateSession(runtimeSessionId, convId, requestedModel, approveAll) {
537
+ const key = sessionKey(runtimeSessionId, convId);
538
+ if (sessions.has(key)) {
539
+ return sessions.get(key);
540
+ }
541
+
542
+ const targetModel = String(requestedModel || config.model || '').trim();
543
+ log(`Creating new session for conversation ${String(convId || 'unknown').slice(0, 8)} runtime=${key.slice(0, 12)}${targetModel ? ` model=${targetModel}` : ''}...`);
544
+
545
+ const sessionConfig = {
546
+ onPermissionRequest: approveAll,
547
+ // Dispatch ask_user calls to the per-turn handler registered in processNext.
548
+ onUserInputRequest: (request) => {
549
+ if (!activeUserInputHandler) throw new Error('ask_user called but no handler is active for this turn');
550
+ return activeUserInputHandler(request);
551
+ },
552
+ };
553
+ if (targetModel) sessionConfig.model = targetModel;
554
+ const session = await client.createSession(sessionConfig);
555
+
556
+ sessions.set(key, session);
557
+
558
+ const actualModel = await getCurrentModelId(session);
559
+ if (targetModel && actualModel && canonicalModelId(actualModel) !== canonicalModelId(targetModel)) {
560
+ // A silent fallback here answers the turn with a model the user never
561
+ // picked (and shows the requested one in the UI). Fail the turn instead;
562
+ // the session must not be reused either, or every retry lands on it.
563
+ sessions.delete(key);
564
+ try {
565
+ await session.dispose?.();
566
+ } catch (_) {}
567
+ throw new Error(`Copilot session started with model "${actualModel}" instead of requested "${targetModel}"; refusing to run the turn on a substituted model`);
568
+ }
569
+
570
+ log(`Session created: ${session.sessionId.slice(0, 8)} key=${key.slice(0, 12)}${actualModel ? ` model=${actualModel}` : ''}`);
571
+
572
+ // Tell the server this SDK session is an execution vehicle for an existing
573
+ // conversation, so the session-state import sweep does not resurrect it as
574
+ // a stand-alone "shadow" conversation. Best effort: older servers without
575
+ // the endpoint just 404.
576
+ const linkedConversationId = String(convId || '').trim();
577
+ if (linkedConversationId) {
578
+ try {
579
+ await apiJson('POST', '/api/relay-session-link', {
580
+ conversationId: linkedConversationId,
581
+ sdkSessionId: session.sessionId,
582
+ }, { auth: true });
583
+ } catch (e) {
584
+ vlog(`relay-session-link report failed: ${e.message}`);
585
+ }
586
+ }
587
+ return session;
588
+ }
589
+
590
+ function normalizeModelId(modelInfo) {
591
+ if (!modelInfo) return null;
592
+ if (typeof modelInfo === 'string') {
593
+ const candidate = normalizeModelIdCandidate(modelInfo);
594
+ return isValidModelId(candidate) ? candidate : null;
595
+ }
596
+ const candidate = normalizeModelIdCandidate(modelInfo.modelId || modelInfo.id || modelInfo.model || null);
597
+ return isValidModelId(candidate) ? candidate : null;
598
+ }
599
+
600
+ function canonicalModelId(id) {
601
+ return String(id || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
602
+ }
603
+
604
+ function appendDescriptorsWithPriority(raw, found, priority) {
605
+ const descriptors = [];
606
+ extractModelDescriptors(raw, descriptors);
607
+ for (const descriptor of descriptors) {
608
+ found.push({ ...descriptor, __priority: priority });
609
+ }
610
+ }
611
+
612
+ async function getAvailableModels(session) {
613
+ const modelApi = session?.rpc?.model;
614
+ const sendRequest = session?.connection?.sendRequest;
615
+ if (!modelApi && typeof sendRequest !== 'function' && !client?.listModels) {
616
+ if (!cachedModelMetadataById.size) return [];
617
+ return [...cachedModelMetadataById.values()];
618
+ }
619
+
620
+ const found = [];
621
+ if (typeof client?.listModels === 'function') {
622
+ try {
623
+ const raw = await client.listModels();
624
+ appendDescriptorsWithPriority(raw, found, 4);
625
+ } catch (_) {
626
+ // Fall through to session-scoped model APIs.
627
+ }
628
+ }
629
+ if (typeof sendRequest === 'function') {
630
+ try {
631
+ const raw = await sendRequest.call(session.connection, 'models.list', {});
632
+ appendDescriptorsWithPriority(raw, found, 3);
633
+ } catch (_) {
634
+ // Ignore and try session-scoped listing.
635
+ }
636
+ }
637
+ if (typeof modelApi?.list === 'function') {
638
+ try {
639
+ const raw = await modelApi.list.call(modelApi, { skipCache: true });
640
+ appendDescriptorsWithPriority(raw, found, 2);
641
+ } catch (_) {
642
+ // Compatibility fallbacks below.
643
+ }
644
+ }
645
+ if (modelApi) {
646
+ for (const fnName of ['getAvailable', 'available', 'getAll', 'list']) {
647
+ const fn = modelApi[fnName];
648
+ if (typeof fn !== 'function') continue;
649
+ try {
650
+ const raw = await fn.call(modelApi);
651
+ appendDescriptorsWithPriority(raw, found, 1);
652
+ } catch (_) {
653
+ // Ignore; we'll still try alias-based switching below.
654
+ }
655
+ }
656
+ }
657
+ found.sort((a, b) => Number(b?.__priority || 0) - Number(a?.__priority || 0));
658
+
659
+ const byModelId = new Map();
660
+ for (const entry of found) {
661
+ const modelId = normalizeModelIdCandidate(entry?.modelId);
662
+ if (!isValidModelId(modelId)) continue;
663
+ const existing = byModelId.get(modelId);
664
+ const cached = cachedModelMetadataById.get(modelId);
665
+ byModelId.set(modelId, {
666
+ modelId,
667
+ contextLimitTokens: existing?.contextLimitTokens
668
+ ?? normalizeContextLimitTokens(entry?.contextLimitTokens)
669
+ ?? cached?.contextLimitTokens
670
+ ?? null,
671
+ longContextLimitTokens: existing?.longContextLimitTokens
672
+ ?? normalizeContextLimitTokens(entry?.longContextLimitTokens)
673
+ ?? cached?.longContextLimitTokens
674
+ ?? null,
675
+ pricing: existing?.pricing || entry?.pricing || cached?.pricing || null,
676
+ });
677
+ }
678
+ const models = byModelId.size ? [...byModelId.values()] : [...cachedModelMetadataById.values()];
679
+ for (const entry of models) cachedModelMetadataById.set(entry.modelId, entry);
680
+ return models;
681
+ }
682
+
683
+ async function getAvailableModelIds(session) {
684
+ const models = await getAvailableModels(session);
685
+ return models.map((entry) => entry.modelId);
686
+ }
687
+
688
+ function buildRequestedModelCandidates(requested) {
689
+ const key = String(requested || '').trim();
690
+ if (!key) return [];
691
+ const aliasList = MODEL_ALIAS_CANDIDATES[key] || MODEL_ALIAS_CANDIDATES[key.toLowerCase()] || [];
692
+ return [...new Set([key, ...aliasList])];
693
+ }
694
+
695
+ function resolveRequestedModelId(requested, availableIds) {
696
+ const candidates = buildRequestedModelCandidates(requested);
697
+ if (!candidates.length) return null;
698
+ if (!availableIds.length) return candidates[0];
699
+
700
+ const byCanonical = new Map(availableIds.map((id) => [canonicalModelId(id), id]));
701
+ for (const candidate of candidates) {
702
+ const exact = availableIds.find((id) => id === candidate);
703
+ if (exact) return exact;
704
+ const canon = byCanonical.get(canonicalModelId(candidate));
705
+ if (canon) return canon;
706
+ }
707
+ return candidates[0];
708
+ }
709
+
710
+ async function getCurrentModelId(session) {
711
+ try {
712
+ const modelInfo = await session.rpc.model.getCurrent();
713
+ return normalizeModelId(modelInfo);
714
+ } catch {
715
+ return null;
716
+ }
717
+ }
718
+
719
+ async function publishModelSnapshot(session, reason = 'standalone-relay', force = false) {
720
+ const now = Date.now();
721
+ if (!force && (now - lastModelSnapshotMs) < MODEL_SNAPSHOT_MIN_INTERVAL_MS) return;
722
+ lastModelSnapshotMs = now;
723
+ try {
724
+ const currentModel = await getCurrentModelId(session);
725
+ const availableModels = await getAvailableModels(session);
726
+ const modelIds = availableModels.map((entry) => entry.modelId);
727
+ const contextLimitsByModel = Object.fromEntries(
728
+ availableModels
729
+ .filter((entry) => entry.contextLimitTokens !== null)
730
+ .map((entry) => [entry.modelId, entry.contextLimitTokens]),
731
+ );
732
+ const modelMetadataByModel = Object.fromEntries(
733
+ availableModels.map((entry) => [entry.modelId, {
734
+ defaultContextLimitTokens: entry.contextLimitTokens,
735
+ longContextLimitTokens: entry.longContextLimitTokens,
736
+ pricing: entry.pricing,
737
+ }]),
738
+ );
739
+ await apiJson('POST', '/api/models/snapshot', {
740
+ source: `standalone-relay:${reason}`,
741
+ models: modelIds,
742
+ contextLimitsByModel,
743
+ modelMetadataByModel,
744
+ currentModel: currentModel || null,
745
+ defaultModel: currentModel || modelIds[0] || null,
746
+ error: null,
747
+ }, { auth: true });
748
+ vlog(`Published model snapshot (${reason}) models=${modelIds.length} contextLimits=${Object.keys(contextLimitsByModel).length} current=${currentModel || 'unknown'}`);
749
+ } catch (e) {
750
+ try {
751
+ await apiJson('POST', '/api/models/snapshot', {
752
+ source: `standalone-relay:${reason}`,
753
+ models: [],
754
+ contextLimitsByModel: {},
755
+ currentModel: null,
756
+ defaultModel: null,
757
+ error: e?.message || String(e),
758
+ }, { auth: true });
759
+ } catch (_) {}
760
+ vlog(`Model snapshot publish failed (${reason}): ${e?.message || String(e)}`);
761
+ }
762
+ }
763
+
764
+ async function setModelForMessage(session, model, contextTier = 'default') {
765
+ const requested = String(model || '').trim();
766
+ if (!requested) return;
767
+
768
+ const current = await getCurrentModelId(session);
769
+ if (canonicalModelId(current) === canonicalModelId(requested) && contextTier === 'default') return;
770
+
771
+ const availableModels = await getAvailableModelIds(session);
772
+ if (VERBOSE && availableModels.length) {
773
+ vlog(`Available models: ${availableModels.join(', ')}`);
774
+ }
775
+ const targetModel = resolveRequestedModelId(requested, availableModels);
776
+ if (!targetModel) return;
777
+
778
+ vlog(`Model switch requested=${requested} resolved=${targetModel} current=${current || 'unknown'}`);
779
+
780
+ const attempts = [
781
+ () => session.rpc.model.switchTo({ modelId: targetModel, contextTier }),
782
+ () => session.rpc.model.setCurrent(targetModel),
783
+ () => session.rpc.model.setCurrent({ modelId: targetModel, contextTier }),
784
+ () => session.rpc.model.setCurrent({ model: targetModel }),
785
+ () => session.rpc.model.set(targetModel),
786
+ () => session.rpc.model.set({ modelId: targetModel }),
787
+ () => session.rpc.model.set({ model: targetModel }),
788
+ ];
789
+
790
+ for (const attempt of attempts) {
791
+ try {
792
+ await attempt();
793
+ const after = await getCurrentModelId(session);
794
+ if (canonicalModelId(after) === canonicalModelId(requested) || canonicalModelId(after) === canonicalModelId(targetModel)) {
795
+ log(`Switched model to ${after || targetModel} (requested ${requested})`);
796
+ return;
797
+ }
798
+ } catch (_) {
799
+ // Continue trying alternate call signatures.
800
+ }
801
+ }
802
+
803
+ err(`Unable to set requested model "${requested}" (resolved as "${targetModel}"); continuing with current model "${current || 'unknown'}"`);
804
+ }
805
+
806
+ // ─── ask_user / Relay Question Bridge ─────────────────────────────────────────
807
+ // Guarded by `busy` — only one turn active at a time, so one handler slot is enough.
808
+ let activeUserInputHandler = null;
809
+
810
+ /**
811
+ * Called by the SDK when the agent invokes the ask_user tool.
812
+ * Posts the question to the web relay, polls until answered, and returns the answer.
813
+ */
814
+ async function relayUserInputQuestion(msg, request) {
815
+ const { question, choices, allowFreeform } = request;
816
+ log(`ask_user question for msg ${msg.id.slice(0, 8)}: "${String(question || '').slice(0, 80)}"`);
817
+
818
+ let questionData;
819
+ try {
820
+ const body = {
821
+ queueId: msg.id,
822
+ messageId: msg.id,
823
+ conversationId: msg.conversationId,
824
+ prompt: question,
825
+ choices: Array.isArray(choices) ? choices : [],
826
+ allowFreeform: typeof allowFreeform === 'boolean' ? allowFreeform : true,
827
+ };
828
+ const result = await apiJson('POST', '/api/relay-question', body, { auth: true });
829
+ questionData = result.question;
830
+ } catch (e) {
831
+ err(`Failed to post ask_user question to relay: ${e.message}`);
832
+ throw new Error(`ask_user relay failed: ${e.message}`);
833
+ }
834
+
835
+ const questionId = questionData.id;
836
+ log(`ask_user question ${questionId.slice(0, 8)} posted — waiting for web answer...`);
837
+
838
+ const POLL_MS = 2000;
839
+ const MAX_WAIT_MS = 10 * 60 * 1000; // 10 min
840
+ const deadline = Date.now() + MAX_WAIT_MS;
841
+
842
+ while (Date.now() < deadline) {
843
+ await new Promise(r => setTimeout(r, POLL_MS));
844
+ let q;
845
+ try {
846
+ const result = await apiJson('GET', `/api/relay-question/${questionId}`, undefined, { auth: true });
847
+ q = result.question;
848
+ } catch (e) {
849
+ vlog(`Poll for question ${questionId.slice(0, 8)} failed: ${e.message}`);
850
+ continue;
851
+ }
852
+
853
+ if (q.status === 'answered') {
854
+ log(`ask_user question ${questionId.slice(0, 8)} answered: "${String(q.answer || '').slice(0, 80)}"`);
855
+ return q.answer || '';
856
+ }
857
+ if (q.status === 'timed_out') {
858
+ throw new Error('ask_user question timed out waiting for web answer');
859
+ }
860
+ }
861
+
862
+ throw new Error('ask_user question exceeded max wait time');
863
+ }
864
+
865
+ // ─── Main Poll Loop ────────────────────────────────────────────────────────────
866
+ let busy = false;
867
+
868
+ async function processNext(approveAll) {
869
+ if (busy || !clientReady) return;
870
+
871
+ let data;
872
+ try {
873
+ data = await apiJson('GET', '/api/pending', undefined, { auth: true });
874
+ } catch (e) {
875
+ err(`Pending poll failed: ${e.message}`);
876
+ return;
877
+ }
878
+
879
+ const msg = data.message;
880
+ if (!msg) return;
881
+
882
+ // Messages from conversations bound to a session-worker provider must never
883
+ // execute here: this relay runs on the Copilot plan, and model ids like
884
+ // claude-opus-5 exist on both sides, so a stolen turn would silently bill
885
+ // the wrong plan under the wrong provider label. The server-side dequeue
886
+ // already filters these; this guard covers a server that predates it.
887
+ // openai is allowed through because routing-disabled installs still run
888
+ // OpenAI BYOK conversations here. Note this process is launched by hand with
889
+ // plain env, so such a turn does execute on the Copilot plan and is reported
890
+ // as a mismatch by the provenance check in /api/response. The eligible set
891
+ // is shared with findPendingForLegacyRelay via shared/provider-routing.mjs,
892
+ // so both sides derive from one constant.
893
+ const msgProviderType = String(msg.providerType || '').trim().toLowerCase();
894
+ if (!isLegacyRelayProviderType(msgProviderType)) {
895
+ err(`Refusing msg ${msg.id.slice(0, 8)}: it belongs to provider "${msgProviderType}", not the Copilot relay`);
896
+ try {
897
+ // The distinct code keeps the row's retry budget and backoff untouched:
898
+ // this is a routing refusal, not a delivery failure — the owner worker
899
+ // must be able to claim it immediately.
900
+ await apiJson('POST', '/api/requeue', { messageId: msg.id, code: 'relay.provider-mismatch' }, { auth: true });
901
+ } catch (_) {}
902
+ return;
903
+ }
904
+
905
+ busy = true;
906
+ const started = Date.now();
907
+ const attachments = Array.isArray(msg.attachments) ? msg.attachments : [];
908
+ const sdkAttachments = buildSdkAttachments(msg);
909
+ const prompt = buildPrompt(msg);
910
+ const promptForLog = redactAttachmentPathsFromPrompt(prompt);
911
+ log(`Processing msg ${msg.id.slice(0, 8)} conv=${msg.conversationId.slice(0, 8)} rs=${String(msg.runtimeSessionId || 'none').slice(0, 8)} model=${msg.model || 'default'}${msg.reasoningEffort ? ` effort=${msg.reasoningEffort}` : ''} len=${(msg.text || '').length}${attachments.length ? ` attachments=${attachments.length}` : ''}`);
912
+ logTextBlock(`PROMPT ${msg.id.slice(0, 8)}`, promptForLog);
913
+
914
+ try {
915
+ const session = await getOrCreateSession(msg.runtimeSessionId, msg.conversationId, msg.model, approveAll);
916
+ // A cached session keeps the model it was created with; without this a
917
+ // second message on the same conversation with a different model silently
918
+ // executed on the old one (the switch helper existed but had no caller).
919
+ await setModelForMessage(session, msg.model);
920
+ await publishModelSnapshot(session, 'process-message');
921
+
922
+ activeUserInputHandler = (request) => relayUserInputQuestion(msg, request);
923
+ let response;
924
+ try {
925
+ const messageOptions = sdkAttachments.length ? { prompt, attachments: sdkAttachments } : { prompt };
926
+ if (msg.reasoningEffort && String(msg.reasoningEffort || '').trim().toLowerCase() !== 'none') {
927
+ messageOptions.reasoningEffort = String(msg.reasoningEffort || '').trim();
928
+ }
929
+ response = await session.sendAndWait(messageOptions, 300_000);
930
+ } finally {
931
+ activeUserInputHandler = null;
932
+ }
933
+ vlog(`sendAndWait completed in ${Date.now() - started}ms for ${msg.id.slice(0, 8)}`);
934
+
935
+ const extracted = extractFinalText(response);
936
+ const text = extracted || buildFallbackAssistantText();
937
+ if (!extracted) {
938
+ // Log raw envelope whenever we fall back so we can improve extraction later
939
+ try {
940
+ log(`Fallback used for msg ${msg.id.slice(0, 8)}. Raw envelope:`, JSON.stringify(response, null, 2).slice(0, 2000));
941
+ } catch {
942
+ log(`Fallback used for msg ${msg.id.slice(0, 8)}. Raw envelope: [unserializable]`);
943
+ }
944
+ }
945
+ const model = await getCurrentModelId(session);
946
+ log(`Response ready (${text.length} chars, model=${model ?? 'unknown'}) for msg ${msg.id.slice(0, 8)}`);
947
+ logTextBlock(`RESPONSE ${msg.id.slice(0, 8)}`, text);
948
+ if (VERBOSE && extracted) {
949
+ try {
950
+ vlog('Raw response envelope:', JSON.stringify(response, null, 2));
951
+ } catch {
952
+ vlog('Raw response envelope: [unserializable]');
953
+ }
954
+ }
955
+
956
+ await apiJson('POST', '/api/response', { messageId: msg.id, conversationId: msg.conversationId, text, model }, { auth: true });
957
+ } catch (e) {
958
+ err(`Failed to process msg ${msg.id.slice(0, 8)}:`, e.message);
959
+ try {
960
+ await apiJson('POST', '/api/requeue', { messageId: msg.id }, { auth: true });
961
+ } catch (_) {}
962
+ } finally {
963
+ busy = false;
964
+ }
965
+ }
966
+
967
+ // ─── Heartbeat ─────────────────────────────────────────────────────────────────
968
+ async function heartbeat() {
969
+ try {
970
+ await apiJson('POST', '/api/heartbeat', undefined, { auth: true });
971
+ } catch (e) {
972
+ err(`Heartbeat failed: ${e.message}`);
973
+ }
974
+ }
975
+
976
+ // ─── Startup ───────────────────────────────────────────────────────────────────
977
+ async function main() {
978
+ log(`Starting relay... (foreground=${FOREGROUND}, verbose=${VERBOSE}, token=${TOKEN_OVERRIDE ? 'overridden' : 'from config'}, sessionPolicy=${RUNTIME_SESSION_POLICY})`);
979
+ vlog(`Server URL: ${SERVER_URL}`);
980
+
981
+ let CopilotClient, approveAll;
982
+ try {
983
+ const sdk = await import(`file:///${SDK_PATH}`);
984
+ CopilotClient = sdk.CopilotClient;
985
+ approveAll = sdk.approveAll;
986
+ if (!CopilotClient) throw new Error('CopilotClient not found in SDK exports');
987
+ log('SDK loaded ✓');
988
+ } catch (e) {
989
+ err('Failed to load Copilot SDK:', e.message);
990
+ process.exit(1);
991
+ }
992
+
993
+ try {
994
+ await initClient(CopilotClient);
995
+ } catch (e) {
996
+ err('Failed to start Copilot client:', e.message);
997
+ process.exit(1);
998
+ }
999
+
1000
+ setInterval(() => processNext(approveAll), 2000);
1001
+ setInterval(heartbeat, 5000);
1002
+ heartbeat();
1003
+ try {
1004
+ const bootSession = await getOrCreateSession('__relay_model_probe__', '__relay_model_probe__', null, approveAll);
1005
+ await publishModelSnapshot(bootSession, 'startup', true);
1006
+ } catch (e) {
1007
+ vlog(`Startup model snapshot skipped: ${e?.message || String(e)}`);
1008
+ }
1009
+
1010
+ log('Relay running. Polling for messages every 2s.');
1011
+ }
1012
+
1013
+ const executedPath = process.argv[1] ? path.resolve(process.argv[1]) : null;
1014
+ if (executedPath && executedPath === path.resolve(fileURLToPath(import.meta.url))) {
1015
+ main().catch((e) => { err('Fatal:', e); process.exit(1); });
1016
+
1017
+ // Keep the process alive through unhandled rejections (e.g. from SDK internals)
1018
+ process.on('unhandledRejection', (reason) => {
1019
+ err('Unhandled rejection (relay will keep running):', reason);
1020
+ });
1021
+ process.on('uncaughtException', (e) => {
1022
+ err('Uncaught exception (relay will keep running):', e.message, e.stack);
1023
+ });
1024
+ }
1025
+
1026
+ export { readConfig, parsePort };