@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,786 @@
1
+ 'use strict';
2
+
3
+ import { execFileSync, spawn } from 'child_process';
4
+ import fs from 'fs';
5
+ import os from 'os';
6
+ import path from 'path';
7
+ import { fileURLToPath } from 'url';
8
+
9
+ function shellQuote(value) {
10
+ return `'${String(value || '').replace(/'/g, `'\\''`)}'`;
11
+ }
12
+
13
+ function parsePositiveInt(value) {
14
+ const num = Number.parseInt(String(value || ''), 10);
15
+ return Number.isInteger(num) && num > 0 ? num : null;
16
+ }
17
+
18
+ function isPidAlive(pidValue) {
19
+ const pid = Number(pidValue);
20
+ if (!Number.isInteger(pid) || pid <= 0) return false;
21
+ try {
22
+ process.kill(pid, 0);
23
+ return true;
24
+ } catch (error) {
25
+ const code = String(error?.code || '').trim().toUpperCase();
26
+ return code === 'EPERM';
27
+ }
28
+ }
29
+
30
+ function normalizeText(value) {
31
+ const text = String(value || '').trim();
32
+ return text || null;
33
+ }
34
+
35
+ export function resolveOpenAIWireApi(model = '') {
36
+ const normalized = String(model || '').trim().toLowerCase().replace(/^openai\//, '');
37
+ if (
38
+ normalized.startsWith('gpt-5')
39
+ || normalized.startsWith('codex-')
40
+ || normalized === 'o1'
41
+ || normalized.startsWith('o1-')
42
+ || normalized === 'o3'
43
+ || normalized.startsWith('o3-')
44
+ || normalized.startsWith('o4-')
45
+ ) {
46
+ return 'responses';
47
+ }
48
+ return 'completions';
49
+ }
50
+
51
+ export function applyOpenAIProviderEnvironment(env = {}, {
52
+ enabled = false,
53
+ apiKey = '',
54
+ model = '',
55
+ baseUrl = 'https://api.openai.com/v1',
56
+ wireApi = '',
57
+ } = {}) {
58
+ const next = { ...env };
59
+ for (const key of [
60
+ 'COPILOT_PROVIDER_TYPE',
61
+ 'COPILOT_PROVIDER_BASE_URL',
62
+ 'COPILOT_PROVIDER_API_KEY',
63
+ 'COPILOT_PROVIDER_WIRE_API',
64
+ 'COPILOT_MODEL',
65
+ ]) {
66
+ delete next[key];
67
+ }
68
+ if (!enabled) return next;
69
+ const normalizedApiKey = normalizeText(apiKey);
70
+ const normalizedModel = normalizeText(model);
71
+ if (!normalizedApiKey) throw new Error('openai-api-key-not-configured');
72
+ if (!normalizedModel) throw new Error('openai-model-not-configured');
73
+ next.COPILOT_PROVIDER_TYPE = 'openai';
74
+ next.COPILOT_PROVIDER_BASE_URL = normalizeText(baseUrl) || 'https://api.openai.com/v1';
75
+ next.COPILOT_PROVIDER_API_KEY = normalizedApiKey;
76
+ next.COPILOT_PROVIDER_WIRE_API = normalizeText(wireApi) || resolveOpenAIWireApi(normalizedModel);
77
+ next.COPILOT_MODEL = normalizedModel;
78
+ return next;
79
+ }
80
+
81
+ /**
82
+ * The worker kind an environment names, or `copilot` (the extension engine,
83
+ * which is the absence of a kind) for anything unrecognised.
84
+ *
85
+ * Validated against `NODE_WORKER_DESCRIPTORS` rather than a parallel list of
86
+ * kind names: a new node worker is then one descriptor entry, and there is no
87
+ * second place that can be forgotten and silently route the new kind back to
88
+ * the Copilot CLI. Referenced lazily — this is only ever called at request
89
+ * time, long after the descriptor table is initialised.
90
+ */
91
+ export function resolveWorkerKind(env = {}) {
92
+ const kind = String(env?.COPILOT_WEB_RELAY_WORKER_KIND || '').trim().toLowerCase();
93
+ return Object.hasOwn(NODE_WORKER_DESCRIPTORS, kind) ? kind : 'copilot';
94
+ }
95
+
96
+ export function applyClaudeProviderEnvironment(env = {}, {
97
+ enabled = false,
98
+ model = '',
99
+ } = {}) {
100
+ const next = { ...env };
101
+ // Only clear the worker kind this provider owns so clear chains stay
102
+ // order-independent when multiple provider appliers run back to back.
103
+ if (resolveWorkerKind(next) === 'claude') delete next.COPILOT_WEB_RELAY_WORKER_KIND;
104
+ delete next.CLAUDE_RELAY_MODEL;
105
+ if (!enabled) return next;
106
+ // The Claude worker authenticates through the host's logged-in Claude
107
+ // credentials, so no API key flows through the environment here.
108
+ next.COPILOT_WEB_RELAY_WORKER_KIND = 'claude';
109
+ const normalizedModel = normalizeText(model);
110
+ if (normalizedModel) next.CLAUDE_RELAY_MODEL = normalizedModel;
111
+ return next;
112
+ }
113
+
114
+ export function applyCursorProviderEnvironment(env = {}, {
115
+ enabled = false,
116
+ model = '',
117
+ apiKey = '',
118
+ } = {}) {
119
+ const next = { ...env };
120
+ if (resolveWorkerKind(next) === 'cursor') delete next.COPILOT_WEB_RELAY_WORKER_KIND;
121
+ delete next.CURSOR_RELAY_MODEL;
122
+ delete next.CURSOR_API_KEY;
123
+ if (!enabled) return next;
124
+ const normalizedApiKey = normalizeText(apiKey);
125
+ if (!normalizedApiKey) throw new Error('cursor-api-key-not-configured');
126
+ next.COPILOT_WEB_RELAY_WORKER_KIND = 'cursor';
127
+ next.CURSOR_API_KEY = normalizedApiKey;
128
+ const normalizedModel = normalizeText(model);
129
+ if (normalizedModel) next.CURSOR_RELAY_MODEL = normalizedModel;
130
+ return next;
131
+ }
132
+
133
+ export function applyGrokProviderEnvironment(env = {}, {
134
+ enabled = false,
135
+ model = '',
136
+ command = '',
137
+ } = {}) {
138
+ const next = { ...env };
139
+ // Host-login provider (like Claude): no API key is required in the relay.
140
+ if (resolveWorkerKind(next) === 'grok') delete next.COPILOT_WEB_RELAY_WORKER_KIND;
141
+ delete next.GROK_RELAY_MODEL;
142
+ delete next.GROK_CLI_COMMAND;
143
+ if (!enabled) return next;
144
+ next.COPILOT_WEB_RELAY_WORKER_KIND = 'grok';
145
+ const normalizedModel = normalizeText(model);
146
+ if (normalizedModel) next.GROK_RELAY_MODEL = normalizedModel;
147
+ const normalizedCommand = normalizeText(command);
148
+ if (normalizedCommand) next.GROK_CLI_COMMAND = normalizedCommand;
149
+ return next;
150
+ }
151
+
152
+ /**
153
+ * The Copilot SDK engine: the same `github`/`openai` providers the extension
154
+ * serves, run as a plain Node worker against the CLI's headless runtime.
155
+ *
156
+ * Unlike the other appliers this one configures no credentials of its own. The
157
+ * SDK worker authenticates exactly like the TUI does (the host's `copilot`
158
+ * login), and the one path it needs — `COPILOT_SDK_PATH` — is already resolved
159
+ * once into the base launch environment for every session, extension or SDK.
160
+ * (It needs no CLI executable: the runtime entry point is derived from the SDK
161
+ * bundle's own version directory, so the two can never drift apart.) This
162
+ * applier's whole job is selecting the worker kind and handing over the model.
163
+ *
164
+ * BYOK is deliberately NOT re-implemented here: an `openai` session is composed
165
+ * as `applyCopilotSdkProviderEnvironment(applyOpenAIProviderEnvironment(...))`,
166
+ * so one OpenAI configuration drives both engines and the secret-env-file
167
+ * plumbing for `COPILOT_PROVIDER_API_KEY` is untouched. The SDK worker reads
168
+ * that family in-process (`copilot-byok-provider.mjs`) because the runtime
169
+ * ignores it for SDK-created sessions.
170
+ *
171
+ * `COPILOT_SDK_PATH` is required rather than optional: it is resolved from the
172
+ * installed CLI's own bundle, so its absence means there is no CLI to run and
173
+ * failing here names the problem instead of spawning a worker that dies on its
174
+ * first import.
175
+ */
176
+ export function applyCopilotSdkProviderEnvironment(env = {}, {
177
+ enabled = false,
178
+ model = '',
179
+ } = {}) {
180
+ const next = { ...env };
181
+ // Only clear the worker kind this provider owns so clear chains stay
182
+ // order-independent when multiple provider appliers run back to back.
183
+ if (resolveWorkerKind(next) === 'copilot-sdk') delete next.COPILOT_WEB_RELAY_WORKER_KIND;
184
+ delete next.COPILOT_RELAY_MODEL;
185
+ if (!enabled) return next;
186
+ if (!normalizeText(next.COPILOT_SDK_PATH)) throw new Error('copilot-sdk-path-not-resolved');
187
+ next.COPILOT_WEB_RELAY_WORKER_KIND = 'copilot-sdk';
188
+ const normalizedModel = normalizeText(model);
189
+ if (normalizedModel) next.COPILOT_RELAY_MODEL = normalizedModel;
190
+ return next;
191
+ }
192
+
193
+ /**
194
+ * Why the Copilot SDK engine cannot be selected right now, or null when it can.
195
+ *
196
+ * The engine toggle is a promise the UI makes on the relay's behalf ("new
197
+ * Copilot conversations run headless"), and there are two ways to save it and
198
+ * have precisely nothing happen — both invisible from the settings panel, and
199
+ * both indistinguishable from the feature simply not working:
200
+ *
201
+ * - `COPILOT_SDK_PATH` never resolved. The worker's very first act is to
202
+ * import the SDK from it, so every conversation would die on spawn.
203
+ * - Session-worker routing is off. No worker of any node kind is ever spawned,
204
+ * so the setting is a no-op while the panel asserts the opposite.
205
+ *
206
+ * Refusing the save with the reason is strictly better than accepting it: the
207
+ * user finds out at the moment they can still act on it.
208
+ */
209
+ export function copilotSdkEngineUnavailableReason({ env = {}, routingEnabled = false } = {}) {
210
+ if (!normalizeText(env?.COPILOT_SDK_PATH)) {
211
+ // Worth spelling out: the launch environment is built ONCE at boot, so
212
+ // installing the CLI now and re-saving without a restart fails identically.
213
+ return 'The Copilot SDK was not found when the relay started (COPILOT_SDK_PATH did not resolve). '
214
+ + 'Install or upgrade the GitHub Copilot CLI, then restart the relay — the launch environment is '
215
+ + 'snapshotted at startup, so a CLI installed since then is not visible yet.';
216
+ }
217
+ if (routingEnabled !== true) {
218
+ return 'The SDK engine requires session worker routing, which is disabled on this relay '
219
+ + '(SESSION_WORKER_ROUTING_ENABLED). With routing off no SDK worker is ever spawned, so the engine '
220
+ + 'setting would have no effect.';
221
+ }
222
+ return null;
223
+ }
224
+
225
+ export function isClaudeWorkerEnvironment(env = {}) {
226
+ return String(env?.COPILOT_WEB_RELAY_WORKER_KIND || '').trim().toLowerCase() === 'claude';
227
+ }
228
+
229
+ export function isCursorWorkerEnvironment(env = {}) {
230
+ return resolveWorkerKind(env) === 'cursor';
231
+ }
232
+
233
+ export function isGrokWorkerEnvironment(env = {}) {
234
+ return resolveWorkerKind(env) === 'grok';
235
+ }
236
+
237
+ export function isCopilotSdkWorkerEnvironment(env = {}) {
238
+ return resolveWorkerKind(env) === 'copilot-sdk';
239
+ }
240
+
241
+ export function resolveClaudeWorkerScriptPath(env = {}) {
242
+ const explicit = normalizeText(env?.COPILOT_WEB_RELAY_CLAUDE_WORKER_PATH);
243
+ if (explicit) return explicit;
244
+ const repoRoot = normalizeText(env?.COPILOT_WEB_RELAY_ROOT);
245
+ if (repoRoot) return path.join(repoRoot, 'server', 'claude-worker', 'claude-session-worker.mjs');
246
+ const serverDir = normalizeText(env?.COPILOT_WEB_RELAY_SERVER_DIR);
247
+ if (serverDir) return path.join(serverDir, 'claude-worker', 'claude-session-worker.mjs');
248
+ return path.join(process.cwd(), 'server', 'claude-worker', 'claude-session-worker.mjs');
249
+ }
250
+
251
+ export function resolveCursorWorkerScriptPath(env = {}) {
252
+ const explicit = normalizeText(env?.COPILOT_WEB_RELAY_CURSOR_WORKER_PATH);
253
+ if (explicit) return explicit;
254
+ const repoRoot = normalizeText(env?.COPILOT_WEB_RELAY_ROOT);
255
+ if (repoRoot) return path.join(repoRoot, 'server', 'cursor-worker', 'cursor-session-worker.mjs');
256
+ const serverDir = normalizeText(env?.COPILOT_WEB_RELAY_SERVER_DIR);
257
+ if (serverDir) return path.join(serverDir, 'cursor-worker', 'cursor-session-worker.mjs');
258
+ return path.join(process.cwd(), 'server', 'cursor-worker', 'cursor-session-worker.mjs');
259
+ }
260
+
261
+ export function resolveGrokWorkerScriptPath(env = {}) {
262
+ const explicit = normalizeText(env?.COPILOT_WEB_RELAY_GROK_WORKER_PATH);
263
+ if (explicit) return explicit;
264
+ const repoRoot = normalizeText(env?.COPILOT_WEB_RELAY_ROOT);
265
+ if (repoRoot) return path.join(repoRoot, 'server', 'grok-worker', 'grok-session-worker.mjs');
266
+ const serverDir = normalizeText(env?.COPILOT_WEB_RELAY_SERVER_DIR);
267
+ if (serverDir) return path.join(serverDir, 'grok-worker', 'grok-session-worker.mjs');
268
+ return path.join(process.cwd(), 'server', 'grok-worker', 'grok-session-worker.mjs');
269
+ }
270
+
271
+ export function resolveCopilotSdkWorkerScriptPath(env = {}) {
272
+ const explicit = normalizeText(env?.COPILOT_WEB_RELAY_COPILOT_SDK_WORKER_PATH);
273
+ if (explicit) return explicit;
274
+ const repoRoot = normalizeText(env?.COPILOT_WEB_RELAY_ROOT);
275
+ if (repoRoot) return path.join(repoRoot, 'server', 'copilot-worker', 'copilot-sdk-session-worker.mjs');
276
+ const serverDir = normalizeText(env?.COPILOT_WEB_RELAY_SERVER_DIR);
277
+ if (serverDir) return path.join(serverDir, 'copilot-worker', 'copilot-sdk-session-worker.mjs');
278
+ return path.join(process.cwd(), 'server', 'copilot-worker', 'copilot-sdk-session-worker.mjs');
279
+ }
280
+
281
+ // Workers that run as plain Node processes (no CLI, no pseudo-TTY). The
282
+ // `copilot` kind is intentionally absent: that is the extension engine, whose
283
+ // launch path (the real TUI under a `script` pseudo-TTY) must stay exactly
284
+ // as-is. `copilot-sdk` is the SAME provider on the headless SDK engine, and it
285
+ // belongs here with Claude/Cursor/Grok.
286
+ const NODE_WORKER_DESCRIPTORS = Object.freeze({
287
+ claude: Object.freeze({ resolveScriptPath: resolveClaudeWorkerScriptPath, windowsTitle: 'Claude Worker' }),
288
+ cursor: Object.freeze({ resolveScriptPath: resolveCursorWorkerScriptPath, windowsTitle: 'Cursor Worker' }),
289
+ grok: Object.freeze({ resolveScriptPath: resolveGrokWorkerScriptPath, windowsTitle: 'Grok Worker' }),
290
+ 'copilot-sdk': Object.freeze({
291
+ resolveScriptPath: resolveCopilotSdkWorkerScriptPath,
292
+ windowsTitle: 'Copilot SDK Worker',
293
+ }),
294
+ });
295
+
296
+ function resolveNodeWorkerDescriptor(env = {}) {
297
+ const descriptor = NODE_WORKER_DESCRIPTORS[resolveWorkerKind(env)];
298
+ if (!descriptor) return null;
299
+ return {
300
+ scriptPath: descriptor.resolveScriptPath(env),
301
+ windowsTitle: descriptor.windowsTitle,
302
+ };
303
+ }
304
+
305
+ function buildPosixWorkerLaunchCommand(targetSessionId, env = {}) {
306
+ const nodeWorker = resolveNodeWorkerDescriptor(env);
307
+ if (nodeWorker) {
308
+ const nodeExecutable = normalizeText(env?.COPILOT_WEB_RELAY_NODE) || 'node';
309
+ return `${shellQuote(nodeExecutable)} ${shellQuote(nodeWorker.scriptPath)} --session-id ${shellQuote(targetSessionId)}`;
310
+ }
311
+ const cliExecutable = normalizeText(env?.COPILOT_WEB_RELAY_CLI_EXECUTABLE)
312
+ || normalizeText(env?.COPILOT_CLI_EXECUTABLE)
313
+ || normalizeText(env?.COPILOT_CLI_PATH)
314
+ || 'copilot';
315
+ return `${shellQuote(cliExecutable)} --allow-all --session-id ${shellQuote(targetSessionId)}`;
316
+ }
317
+
318
+ export function normalizeTmuxSessionName(targetSessionId) {
319
+ const text = String(targetSessionId || '').trim();
320
+ if (!text) throw new Error('missing-target-session-id');
321
+ if (!/^[A-Za-z0-9_-]+$/.test(text)) {
322
+ throw new Error(`invalid-tmux-session-name:${text}`);
323
+ }
324
+ return text;
325
+ }
326
+
327
+ export function isTmuxAvailable({
328
+ platform = process.platform,
329
+ execFileSyncImpl = execFileSync,
330
+ } = {}) {
331
+ if (platform === 'win32') return false;
332
+ try {
333
+ execFileSyncImpl('tmux', ['-V'], {
334
+ stdio: ['ignore', 'ignore', 'ignore'],
335
+ });
336
+ return true;
337
+ } catch {
338
+ return false;
339
+ }
340
+ }
341
+
342
+ export function tmuxSessionExists(sessionName, {
343
+ execFileSyncImpl = execFileSync,
344
+ } = {}) {
345
+ try {
346
+ execFileSyncImpl('tmux', ['has-session', '-t', sessionName], {
347
+ stdio: ['ignore', 'ignore', 'ignore'],
348
+ });
349
+ return true;
350
+ } catch {
351
+ return false;
352
+ }
353
+ }
354
+
355
+ export function killTmuxSession(sessionName, {
356
+ execFileSyncImpl = execFileSync,
357
+ } = {}) {
358
+ if (!tmuxSessionExists(sessionName, { execFileSyncImpl })) return false;
359
+ try {
360
+ execFileSyncImpl('tmux', ['kill-session', '-t', sessionName], {
361
+ stdio: ['ignore', 'ignore', 'ignore'],
362
+ });
363
+ return true;
364
+ } catch {
365
+ return false;
366
+ }
367
+ }
368
+
369
+ export function getTmuxPanePid(sessionName, {
370
+ execFileSyncImpl = execFileSync,
371
+ } = {}) {
372
+ try {
373
+ const output = execFileSyncImpl('tmux', ['list-panes', '-t', sessionName, '-F', '#{pane_pid}'], {
374
+ stdio: ['ignore', 'pipe', 'ignore'],
375
+ });
376
+ const firstLine = String(output || '').trim().split(/\r?\n/).find(Boolean) || '';
377
+ return parsePositiveInt(firstLine);
378
+ } catch {
379
+ return null;
380
+ }
381
+ }
382
+
383
+ export const WORKER_SECRET_ENV_VARS = Object.freeze(['COPILOT_PROVIDER_API_KEY', 'CURSOR_API_KEY']);
384
+
385
+ export function createWorkerSecretEnvFile(env = {}, {
386
+ fsImpl = fs,
387
+ tempRoot = os.tmpdir(),
388
+ } = {}) {
389
+ const secretLines = [];
390
+ for (const key of WORKER_SECRET_ENV_VARS) {
391
+ const value = String(env?.[key] || '').trim();
392
+ if (!value) continue;
393
+ secretLines.push(`export ${key}=${shellQuote(value)}\n`);
394
+ }
395
+ if (!secretLines.length) return null;
396
+ const directoryPath = fsImpl.mkdtempSync(path.join(tempRoot, 'copilot-relay-worker-'));
397
+ fsImpl.chmodSync(directoryPath, 0o700);
398
+ const filePath = path.join(directoryPath, 'provider.env');
399
+ fsImpl.writeFileSync(
400
+ filePath,
401
+ secretLines.join(''),
402
+ { encoding: 'utf8', mode: 0o600 },
403
+ );
404
+ fsImpl.chmodSync(filePath, 0o600);
405
+ return {
406
+ filePath,
407
+ cleanup() {
408
+ fsImpl.rmSync(filePath, { force: true });
409
+ fsImpl.rmdirSync(directoryPath);
410
+ },
411
+ };
412
+ }
413
+
414
+ /**
415
+ * Where a worker's stdout/stderr land. Before this existed workers ran with
416
+ * their output discarded (`stdio: 'ignore'` / bare tmux exec), so a crash
417
+ * left zero forensic trail — the failure mode that made the 2026-08-11
418
+ * incident undiagnosable. Best-effort by design: a log problem must never
419
+ * block a worker spawn. Naive rotation: >10 MB rolls to `<file>.1`.
420
+ */
421
+ export function prepareWorkerLogFile(targetSessionId, launchEnv = {}, {
422
+ fsImpl = fs,
423
+ pathImpl = path,
424
+ } = {}) {
425
+ try {
426
+ const explicitDir = normalizeText(launchEnv?.COPILOT_WEB_RELAY_LOG_DIR);
427
+ const serverDir = normalizeText(launchEnv?.COPILOT_WEB_RELAY_SERVER_DIR);
428
+ // `new URL(...).pathname` yields '/C:/git/...' on Windows, which pathImpl.join
429
+ // then mangles into '\C:\git\...'; fileURLToPath decodes it to a real host path.
430
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
431
+ const baseDir = explicitDir
432
+ || (serverDir ? pathImpl.join(serverDir, 'logs') : pathImpl.join(moduleDir, '..', 'logs'));
433
+ fsImpl.mkdirSync(baseDir, { recursive: true });
434
+ const safeId = String(targetSessionId || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64) || 'worker';
435
+ const logPath = pathImpl.join(baseDir, `worker-${safeId}.log`);
436
+ try {
437
+ const stats = fsImpl.statSync(logPath);
438
+ if (stats.size > 10 * 1024 * 1024) fsImpl.renameSync(logPath, `${logPath}.1`);
439
+ } catch {}
440
+ return logPath;
441
+ } catch {
442
+ return null;
443
+ }
444
+ }
445
+
446
+ export function buildTmuxWorkerShellCommand(targetSessionId, env = {}, {
447
+ secretEnvFilePath = '',
448
+ workerLogPath = '',
449
+ } = {}) {
450
+ const launchEnv = {
451
+ ...env,
452
+ SESSION_ID: String(targetSessionId || '').trim() || String(env?.SESSION_ID || '').trim(),
453
+ };
454
+ const hasSecretEnvValue = WORKER_SECRET_ENV_VARS
455
+ .some((key) => String(launchEnv[key] || '').trim());
456
+ const normalizedSecretEnvFilePath = String(secretEnvFilePath || '').trim();
457
+ if (hasSecretEnvValue && !normalizedSecretEnvFilePath) {
458
+ throw new Error('worker-secret-env-file-required');
459
+ }
460
+ const exports = [];
461
+ for (const key of [
462
+ 'COPILOT_ALLOW_ALL',
463
+ 'GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS',
464
+ 'COPILOT_WEB_RELAY_ROOT',
465
+ 'COPILOT_WEB_RELAY_SERVER_DIR',
466
+ 'COPILOT_WEB_RELAY_CONFIG',
467
+ 'COPILOT_WEB_RELAY_TOOLS',
468
+ 'COPILOT_WEB_RELAY_LOG_DIR',
469
+ 'COPILOT_WEB_RELAY_CLI_EXECUTABLE',
470
+ 'COPILOT_WEB_RELAY_EXTENSION_BOOTSTRAP_PATH',
471
+ 'COPILOT_SDK_PATH',
472
+ 'EXTENSION_PATH',
473
+ 'COPILOT_PROVIDER_TYPE',
474
+ 'COPILOT_PROVIDER_BASE_URL',
475
+ 'COPILOT_PROVIDER_WIRE_API',
476
+ 'COPILOT_MODEL',
477
+ 'COPILOT_WEB_RELAY_WORKER_KIND',
478
+ // The SDK worker's own model and BYOK context overrides. `COPILOT_MODEL`
479
+ // above is the extension engine's model variable and is read by the CLI's
480
+ // startup layer; the SDK worker takes its default model from
481
+ // `COPILOT_RELAY_MODEL`, matching every other Node worker's
482
+ // `*_RELAY_MODEL`. Without it here the model would silently drop to the
483
+ // runtime default on the tmux path while working on the detached path,
484
+ // which inherits the whole environment.
485
+ 'COPILOT_RELAY_MODEL',
486
+ 'COPILOT_PROVIDER_MAX_PROMPT_TOKENS',
487
+ 'COPILOT_PROVIDER_MAX_OUTPUT_TOKENS',
488
+ // Without this the tmux path would fall back to ~/.claude while the relay
489
+ // (and the detached spawn path, which inherits the full environment) uses
490
+ // the override — worker and relay would read different config roots.
491
+ 'CLAUDE_CONFIG_DIR',
492
+ // Same divergence, one variable over: the relay binds a resolved `claude`
493
+ // binary onto its own environment (cli-install-service.mjs §4.3), and the
494
+ // detached path inherits it for free. Without it here a bound binary would
495
+ // be silently dropped on the tmux path and the worker would fall back to
496
+ // whatever `claude` PATH happens to resolve — exactly the failure mode
497
+ // CLAUDE_CONFIG_DIR above was added for. GROK_CLI_COMMAND, its Grok
498
+ // counterpart, has always been on this list.
499
+ 'CLAUDE_CODE_EXECUTABLE',
500
+ 'CLAUDE_RELAY_MODEL',
501
+ 'CURSOR_RELAY_MODEL',
502
+ 'CURSOR_AGENT_STORE_DIR',
503
+ 'GROK_RELAY_MODEL',
504
+ 'GROK_CLI_COMMAND',
505
+ 'GROK_ALWAYS_APPROVE',
506
+ 'SESSION_ID',
507
+ 'COPILOT_WORKSPACE_ROOT',
508
+ 'INIT_CWD',
509
+ ]) {
510
+ const value = String(launchEnv?.[key] || '').trim();
511
+ if (!value) continue;
512
+ exports.push(`${key}=${shellQuote(value)}`);
513
+ }
514
+ const prefix = exports.length ? `${exports.map((entry) => `export ${entry};`).join(' ')} ` : '';
515
+ const secretPrefix = normalizedSecretEnvFilePath
516
+ ? `. ${shellQuote(normalizedSecretEnvFilePath)} || exit $?; rm -f ${shellQuote(normalizedSecretEnvFilePath)}; rmdir ${shellQuote(path.dirname(normalizedSecretEnvFilePath))}; `
517
+ : '';
518
+ const workerCommand = buildPosixWorkerLaunchCommand(targetSessionId, launchEnv);
519
+ if (resolveNodeWorkerDescriptor(launchEnv)) {
520
+ // Node workers (Claude, Cursor, Grok) are plain processes; no pseudo-TTY
521
+ // needed. Their output is teed to the worker log so a crash leaves a
522
+ // trail (the Copilot CLI keeps its own logs and draws a TUI, so it is
523
+ // deliberately not teed).
524
+ const teeSuffix = String(workerLogPath || '').trim()
525
+ ? ` >> ${shellQuote(workerLogPath)} 2>&1`
526
+ : '';
527
+ return `${prefix}${secretPrefix}exec ${workerCommand}${teeSuffix}`;
528
+ }
529
+ // Use script to create a pseudo-TTY without GH_FORCE_TTY so the CLI routes
530
+ // ask_user requests through the SDK's onUserInputRequest handler instead of
531
+ // drawing terminal prompts.
532
+ return `${prefix}${secretPrefix}exec script -q -c ${shellQuote(workerCommand)} /dev/null`;
533
+ }
534
+
535
+ function sleep(ms) {
536
+ return new Promise((resolve) => setTimeout(resolve, ms));
537
+ }
538
+
539
+ function buildWorkerLaunchEnv({ processCwd, workspaceRoot, env = process.env } = {}) {
540
+ const launchProcessCwd = String(processCwd || '').trim();
541
+ const launchWorkspaceRoot = String(workspaceRoot || '').trim() || launchProcessCwd;
542
+ if (!launchProcessCwd && !launchWorkspaceRoot) return env;
543
+ const launchEnv = {
544
+ ...env,
545
+ COPILOT_ALLOW_ALL: 'true',
546
+ GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS: 'true',
547
+ ...(launchWorkspaceRoot ? {
548
+ COPILOT_WORKSPACE_ROOT: launchWorkspaceRoot,
549
+ INIT_CWD: launchWorkspaceRoot,
550
+ } : {}),
551
+ ...(launchProcessCwd ? { PWD: launchProcessCwd } : {}),
552
+ };
553
+ // COPILOT_ALLOW_ALL matches the --allow-all launch flag and lets headless
554
+ // workers pass folder trust before extension activation. The global wrapper
555
+ // handles outside-package workers and defers to the project extension inside
556
+ // this package, so workers must not force both.
557
+ delete launchEnv.COPILOT_WEB_RELAY_FORCE_GLOBAL_EXTENSION;
558
+ return launchEnv;
559
+ }
560
+
561
+ export async function launchSessionCli({
562
+ targetSessionId,
563
+ cwd,
564
+ processCwd = '',
565
+ workspaceRoot = '',
566
+ env = process.env,
567
+ platform = process.platform,
568
+ spawnImpl = spawn,
569
+ execFileSyncImpl = execFileSync,
570
+ processInspector = null,
571
+ tmuxPollAttempts = 4,
572
+ tmuxPollDelayMs = 200,
573
+ detachedPollAttempts = 10,
574
+ detachedPollDelayMs = 200,
575
+ allowProcessReuse = true,
576
+ createSecretEnvFileImpl = createWorkerSecretEnvFile,
577
+ } = {}) {
578
+ const target = String(targetSessionId || '').trim();
579
+ if (!target) throw new Error('missing-target-session-id');
580
+
581
+ // Kill switch for test/e2e servers: a relay started with this env var must
582
+ // never spawn real session workers (Copilot CLI clients or Claude workers).
583
+ if (String(env?.COPILOT_WEB_RELAY_DISABLE_CLI_SPAWN || '').trim()) {
584
+ throw new Error('cli-spawn-disabled');
585
+ }
586
+
587
+ if (allowProcessReuse) {
588
+ const liveProcess = typeof processInspector?.findProcessForSession === 'function'
589
+ ? processInspector.findProcessForSession(target)
590
+ : null;
591
+ const liveProcessPid = parsePositiveInt(liveProcess?.processId);
592
+ if (liveProcessPid && isPidAlive(liveProcessPid)) {
593
+ return {
594
+ pid: liveProcessPid,
595
+ reused: true,
596
+ launchMode: platform === 'win32' ? 'detached' : 'existing',
597
+ tmuxSessionName: null,
598
+ };
599
+ }
600
+ }
601
+
602
+ const launchWorkspaceRoot = String(workspaceRoot || cwd || process.cwd());
603
+ const launchProcessCwd = String(processCwd || cwd || process.cwd());
604
+ const launchEnv = buildWorkerLaunchEnv({
605
+ processCwd: launchProcessCwd,
606
+ workspaceRoot: launchWorkspaceRoot,
607
+ env,
608
+ });
609
+ const launchSessionEnv = {
610
+ ...launchEnv,
611
+ SESSION_ID: target,
612
+ };
613
+
614
+ if (isTmuxAvailable({ platform, execFileSyncImpl })) {
615
+ const sessionName = normalizeTmuxSessionName(target);
616
+ const tmuxEnv = { ...launchSessionEnv };
617
+ delete tmuxEnv.TMUX;
618
+ delete tmuxEnv.TMUX_PANE;
619
+ delete tmuxEnv.COPILOT_PROVIDER_API_KEY;
620
+ delete tmuxEnv.CURSOR_API_KEY;
621
+ if (allowProcessReuse) {
622
+ const existingPanePid = getTmuxPanePid(sessionName, { execFileSyncImpl });
623
+ if (existingPanePid && isPidAlive(existingPanePid)) {
624
+ return {
625
+ pid: existingPanePid,
626
+ reused: true,
627
+ launchMode: 'tmux',
628
+ tmuxSessionName: sessionName,
629
+ };
630
+ }
631
+ }
632
+ killTmuxSession(sessionName, { execFileSyncImpl });
633
+ const secretEnvFile = createSecretEnvFileImpl(launchSessionEnv);
634
+ const tmuxWorkerLogPath = resolveNodeWorkerDescriptor(launchSessionEnv)
635
+ ? prepareWorkerLogFile(target, launchSessionEnv)
636
+ : null;
637
+ try {
638
+ execFileSyncImpl('tmux', [
639
+ 'new-session',
640
+ '-d',
641
+ '-s',
642
+ sessionName,
643
+ '-c',
644
+ launchProcessCwd,
645
+ 'sh',
646
+ '-lc',
647
+ buildTmuxWorkerShellCommand(target, launchSessionEnv, {
648
+ secretEnvFilePath: secretEnvFile?.filePath,
649
+ workerLogPath: tmuxWorkerLogPath || '',
650
+ }),
651
+ ], {
652
+ env: tmuxEnv,
653
+ stdio: ['ignore', 'ignore', 'ignore'],
654
+ });
655
+ } catch (error) {
656
+ secretEnvFile?.cleanup?.();
657
+ throw error;
658
+ }
659
+ const attempts = Math.max(1, Number(tmuxPollAttempts) || 1);
660
+ for (let index = 0; index < attempts; index += 1) {
661
+ await sleep(Math.max(50, Number(tmuxPollDelayMs) || 200));
662
+ const processMatch = typeof processInspector?.findProcessForSession === 'function'
663
+ ? processInspector.findProcessForSession(target)
664
+ : null;
665
+ if (processMatch?.processId) {
666
+ return {
667
+ pid: Number(processMatch.processId),
668
+ reused: false,
669
+ launchMode: 'tmux',
670
+ tmuxSessionName: sessionName,
671
+ };
672
+ }
673
+ const panePid = getTmuxPanePid(sessionName, { execFileSyncImpl });
674
+ if (panePid && isPidAlive(panePid)) {
675
+ return {
676
+ pid: panePid,
677
+ reused: false,
678
+ launchMode: 'tmux',
679
+ tmuxSessionName: sessionName,
680
+ };
681
+ }
682
+ }
683
+ throw new Error('worker-spawn-unhealthy:tmux-pane-missing');
684
+ }
685
+
686
+ const nodeWorker = resolveNodeWorkerDescriptor(launchSessionEnv);
687
+ const nodeWorkerExecutable = normalizeText(launchSessionEnv.COPILOT_WEB_RELAY_NODE) || 'node';
688
+ const posixCliExecutable = normalizeText(launchSessionEnv.COPILOT_WEB_RELAY_CLI_EXECUTABLE)
689
+ || normalizeText(launchSessionEnv.COPILOT_CLI_EXECUTABLE)
690
+ || normalizeText(launchSessionEnv.COPILOT_CLI_PATH)
691
+ || 'copilot';
692
+ const spawnCommand = platform === 'win32'
693
+ ? (launchSessionEnv.ComSpec || process.env.ComSpec || 'cmd.exe')
694
+ : (nodeWorker ? nodeWorkerExecutable : posixCliExecutable);
695
+ const spawnArgs = platform === 'win32'
696
+ ? (nodeWorker
697
+ ? [
698
+ '/d',
699
+ '/s',
700
+ '/c',
701
+ 'start',
702
+ `${nodeWorker.windowsTitle} ${target.slice(0, 8)}`,
703
+ nodeWorkerExecutable,
704
+ nodeWorker.scriptPath,
705
+ '--session-id',
706
+ target,
707
+ ]
708
+ : [
709
+ '/d',
710
+ '/s',
711
+ '/c',
712
+ 'start',
713
+ `Copilot Worker ${target.slice(0, 8)}`,
714
+ 'gh',
715
+ 'copilot',
716
+ '--',
717
+ '--allow-all',
718
+ '--session-id',
719
+ target,
720
+ ])
721
+ : (nodeWorker
722
+ ? [nodeWorker.scriptPath, '--session-id', target]
723
+ : ['--allow-all', '--session-id', target]);
724
+ // POSIX node workers tee stdout/stderr into the worker log (a crash must
725
+ // leave a trail). On win32 the `start` intermediary opens its own console,
726
+ // so fd inheritance cannot reach the worker — its window shows the output.
727
+ let detachedStdio = 'ignore';
728
+ if (platform !== 'win32' && nodeWorker) {
729
+ const workerLogPath = prepareWorkerLogFile(target, launchSessionEnv);
730
+ if (workerLogPath) {
731
+ try {
732
+ const logFd = fs.openSync(workerLogPath, 'a');
733
+ detachedStdio = ['ignore', logFd, logFd];
734
+ } catch {
735
+ detachedStdio = 'ignore';
736
+ }
737
+ }
738
+ }
739
+ const child = spawnImpl(spawnCommand, spawnArgs, {
740
+ cwd: launchProcessCwd,
741
+ env: launchSessionEnv,
742
+ detached: true,
743
+ stdio: detachedStdio,
744
+ windowsHide: platform !== 'win32',
745
+ });
746
+ child.unref?.();
747
+ if (Array.isArray(detachedStdio) && typeof detachedStdio[1] === 'number') {
748
+ // The child inherited the descriptor; the parent's copy must not leak.
749
+ try { fs.closeSync(detachedStdio[1]); } catch {}
750
+ }
751
+ if (platform === 'win32') {
752
+ const attempts = Math.max(1, Number(detachedPollAttempts) || 1);
753
+ for (let index = 0; index < attempts; index += 1) {
754
+ await sleep(Math.max(50, Number(detachedPollDelayMs) || 200));
755
+ const processMatch = typeof processInspector?.findProcessForSession === 'function'
756
+ ? processInspector.findProcessForSession(target)
757
+ : null;
758
+ const processPid = parsePositiveInt(processMatch?.processId);
759
+ if (processPid && isPidAlive(processPid)) {
760
+ return {
761
+ pid: processPid,
762
+ reused: false,
763
+ launchMode: 'console',
764
+ tmuxSessionName: null,
765
+ child,
766
+ };
767
+ }
768
+ }
769
+ return {
770
+ pid: null,
771
+ reused: false,
772
+ launchMode: 'console',
773
+ tmuxSessionName: null,
774
+ child,
775
+ };
776
+ }
777
+ const pid = parsePositiveInt(child?.pid);
778
+ if (!pid) throw new Error('worker-spawn-unhealthy:missing-pid');
779
+ return {
780
+ pid,
781
+ reused: false,
782
+ launchMode: 'detached',
783
+ tmuxSessionName: null,
784
+ child,
785
+ };
786
+ }