@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,236 @@
1
+ // Shared test harness for the Claude session runner suites
2
+ // (claude-session-process.test.mjs and
3
+ // claude-session-process.routes-integration.test.mjs): the scripted SDK
4
+ // stand-ins, SDK message builders, api stub, and runner factory they both use.
5
+ // Not a *.test.mjs file on purpose — the node --test glob must not pick it up.
6
+
7
+ import { createClaudeSessionRunner } from './claude-session-process.mjs';
8
+
9
+ // Keeps the tests off the real `~/.claude/projects`; transcript relocation has
10
+ // its own suite in claude-transcript-relocator.test.mjs.
11
+ export const noopRelocate = () => ({ status: 'skipped' });
12
+
13
+ export const tick = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms));
14
+
15
+ export async function waitFor(predicate, { timeoutMs = 3000, label = 'condition' } = {}) {
16
+ const start = Date.now();
17
+ for (;;) {
18
+ const value = predicate();
19
+ if (value) return value;
20
+ if (Date.now() - start > timeoutMs) throw new Error(`waitFor timed out: ${label}`);
21
+ await tick();
22
+ }
23
+ }
24
+
25
+ export function makeApiStub({ failRoutes = new Set(), continuationIds = [] } = {}) {
26
+ const calls = [];
27
+ let continuationCounter = 0;
28
+ return {
29
+ calls,
30
+ api: async (method, routePath, body) => {
31
+ calls.push({ method, routePath, body });
32
+ if (failRoutes.has(routePath)) throw new Error(`stubbed failure for ${routePath}`);
33
+ if (routePath === '/api/continuation-turn') {
34
+ continuationCounter += 1;
35
+ return { messageId: continuationIds[continuationCounter - 1] || `cont-${continuationCounter}` };
36
+ }
37
+ return { ok: true };
38
+ },
39
+ };
40
+ }
41
+
42
+ /**
43
+ * A scriptable stand-in for the SDK Query: the test emits SDK messages when it
44
+ * chooses, the stream only ends on endInput/close (like the real CLI), and
45
+ * pushed user turns are recorded (and optionally echoed back as replays).
46
+ * NOTE: the real CLI replays a pushed message when it DEQUEUES it — between
47
+ * turns for queued-behind messages, or mid-turn when it absorbs the push as
48
+ * steering — never at push time. echoPushes replays at push time, so it only
49
+ * models pushes made while no turn is active; scripts that queue a message
50
+ * behind a running turn must emit the replay manually at the dequeue point.
51
+ */
52
+ export function scriptedTurn({ echoPushes = false } = {}) {
53
+ const queued = [];
54
+ let wake = null;
55
+ let ended = false;
56
+ const turn = {
57
+ pushed: [],
58
+ endInputCalls: 0,
59
+ closed: false,
60
+ interrupts: 0,
61
+ stoppedTasks: [],
62
+ pushUserMessage(content) {
63
+ if (ended) throw new Error('user message stream already ended');
64
+ turn.pushed.push(content);
65
+ if (echoPushes) {
66
+ turn.emit({ type: 'user', parent_tool_use_id: null, message: { role: 'user', content } });
67
+ }
68
+ },
69
+ endInput() {
70
+ turn.endInputCalls += 1;
71
+ ended = true;
72
+ wake?.();
73
+ },
74
+ close() {
75
+ turn.closed = true;
76
+ ended = true;
77
+ wake?.();
78
+ },
79
+ async interrupt() {
80
+ turn.interrupts += 1;
81
+ },
82
+ async stopTask(taskId) {
83
+ turn.stoppedTasks.push(taskId);
84
+ },
85
+ emit(message) {
86
+ queued.push(message);
87
+ wake?.();
88
+ },
89
+ fail(error) {
90
+ queued.push({ __throw: error });
91
+ wake?.();
92
+ },
93
+ async* [Symbol.asyncIterator]() {
94
+ for (;;) {
95
+ while (queued.length) {
96
+ const next = queued.shift();
97
+ if (next?.__throw) throw next.__throw;
98
+ yield next;
99
+ }
100
+ if (ended) return;
101
+ await new Promise((resolve) => { wake = resolve; });
102
+ wake = null;
103
+ }
104
+ },
105
+ };
106
+ return turn;
107
+ }
108
+
109
+ /** Emits the given messages, then the stream ends by itself. */
110
+ export function fakeTurn(messages) {
111
+ return {
112
+ pushUserMessage() {},
113
+ endInput() {},
114
+ async* [Symbol.asyncIterator]() {
115
+ for (const message of messages) yield message;
116
+ },
117
+ };
118
+ }
119
+
120
+ export function initMessage(sessionId) {
121
+ return { type: 'system', subtype: 'init', session_id: sessionId, model: 'claude-sonnet-5' };
122
+ }
123
+
124
+ export function resultMessage(text, sessionId) {
125
+ return {
126
+ type: 'result', subtype: 'success', is_error: false, result: text, session_id: sessionId, num_turns: 1, duration_api_ms: 100,
127
+ };
128
+ }
129
+
130
+ export function phantomResultMessage(sessionId) {
131
+ return {
132
+ type: 'result', subtype: 'success', is_error: false, result: '', session_id: sessionId, num_turns: 0, duration_api_ms: 0,
133
+ };
134
+ }
135
+
136
+ export function backgroundTasksMessage(tasks) {
137
+ return { type: 'system', subtype: 'background_tasks_changed', tasks };
138
+ }
139
+
140
+ export function taskNotificationMessage(taskId, status = 'completed', { skipTranscript = false } = {}) {
141
+ return {
142
+ type: 'system',
143
+ subtype: 'task_notification',
144
+ task_id: taskId,
145
+ status,
146
+ summary: 'settled',
147
+ skip_transcript: skipTranscript,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * The live wire shape: snake_case under `compact_metadata`, and `post_tokens`
153
+ * absent on every auto-compact payload observed (conv 563e252e, 2026-08-20).
154
+ */
155
+ export function compactBoundaryMessage({ preTokens = 614117, postTokens = null, trigger = 'auto' } = {}) {
156
+ return {
157
+ type: 'system',
158
+ subtype: 'compact_boundary',
159
+ compact_metadata: {
160
+ trigger,
161
+ pre_tokens: preTokens,
162
+ ...(postTokens === null ? {} : { post_tokens: postTokens }),
163
+ },
164
+ };
165
+ }
166
+
167
+ /**
168
+ * The CLI's compaction announcement. Emitted ONCE, when compaction starts: the
169
+ * bundle's 30 s re-emit is gated on the remote-control client's activity
170
+ * callback, which the plain SDK `query()` this worker uses never registers.
171
+ */
172
+ export function compactingStatusMessage(status = 'compacting') {
173
+ return { type: 'system', subtype: 'status', status };
174
+ }
175
+
176
+ /**
177
+ * The INFERRED shape of what re-opens the turn after an auto-compaction: the
178
+ * CLI's own summary message, which matches no delivered entry's text. Read off
179
+ * the on-disk transcript (`isCompactSummary: true`) plus the runner's observed
180
+ * behaviour in conv 563e252e — not captured from the SDK stream. What the
181
+ * tests actually pin is the runner's response to ANY turn-opening user message
182
+ * that follows a compaction and matches nothing pending.
183
+ */
184
+ export function compactSummaryReplay() {
185
+ return userReplay('This session is being continued from a previous conversation that ran out of context.\n\nSummary:\n1. Primary Request…');
186
+ }
187
+
188
+ export function userReplay(text) {
189
+ return { type: 'user', parent_tool_use_id: null, message: { role: 'user', content: [{ type: 'text', text }] } };
190
+ }
191
+
192
+ /**
193
+ * A settled task's continuation as the SDK actually stamps it: `origin.kind`
194
+ * carries the provenance (SDKMessageOrigin) and `content` is a plain string,
195
+ * not a block array. Deliberately WITHOUT the `<task-notification>` tag in the
196
+ * text, so anything that passes only by tag-sniffing fails here.
197
+ */
198
+ export function taskNotificationReplay(text = 'The background agent has finished its work.') {
199
+ return {
200
+ type: 'user',
201
+ parent_tool_use_id: null,
202
+ origin: { kind: 'task-notification' },
203
+ message: { role: 'user', content: text },
204
+ };
205
+ }
206
+
207
+ export function assistantText(text) {
208
+ return { type: 'assistant', parent_tool_use_id: null, message: { content: [{ type: 'text', text }] } };
209
+ }
210
+
211
+ // Frozen: tests derive per-test messages via spread ({ ...baseMessage, ... }),
212
+ // never by mutating this object.
213
+ export const baseMessage = Object.freeze({
214
+ id: 'q-1',
215
+ conversationId: 'conv-1',
216
+ relayMode: 'agent',
217
+ text: 'hello',
218
+ model: 'claude-sonnet-5',
219
+ attachments: [],
220
+ });
221
+
222
+ export function makeRunner({ stub, startImpl, ...overrides }) {
223
+ return createClaudeSessionRunner({
224
+ api: stub.api,
225
+ sdkSessionId: 'conv-1',
226
+ cwd: '/tmp',
227
+ relocateTranscriptImpl: noopRelocate,
228
+ startClaudeSessionImpl: startImpl,
229
+ continuationRetryDelayMs: 10,
230
+ ...overrides,
231
+ });
232
+ }
233
+
234
+ export async function settled(runner) {
235
+ await waitFor(() => !runner._getProcess(), { label: 'process teardown' });
236
+ }
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ // Claude session worker: a per-conversation Node process that speaks the same
3
+ // relay contracts as the Copilot CLI workers (worker WebSocket, heartbeat,
4
+ // control polling, activity channels) but executes turns through the Claude
5
+ // Agent SDK using the host machine's logged-in Claude credentials.
6
+ import path from 'path';
7
+ import { fileURLToPath } from 'url';
8
+
9
+ import {
10
+ loadTokenFromConfig,
11
+ resolveRelayServerUrl,
12
+ } from '../../.github/extensions/web-relay/runtime/config-loader.mjs';
13
+ import { createApiClient } from '../../.github/extensions/web-relay/runtime/api-client.mjs';
14
+ import { createWorkerWebSocketLink } from '../../.github/extensions/web-relay/runtime/worker-websocket-link.mjs';
15
+ import { createHeartbeatController } from '../../.github/extensions/web-relay/polling/heartbeat.mjs';
16
+ import { createControlPoller } from '../../shared/control-poller.mjs';
17
+ import { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
18
+ import { resolveDeliveredThinking } from '../../shared/claude-thinking.mjs';
19
+ import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
20
+ import { createClaudeSessionRunner } from './claude-session-process.mjs';
21
+
22
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
+ const HEARTBEAT_MS = 10_000;
24
+
25
+ function parseSessionIdArg(argv = process.argv) {
26
+ const index = argv.indexOf('--session-id');
27
+ if (index !== -1 && argv[index + 1]) return String(argv[index + 1]).trim();
28
+ const inline = argv.find((arg) => String(arg || '').startsWith('--session-id='));
29
+ if (inline) return String(inline.split('=')[1] || '').trim();
30
+ return String(process.env.SESSION_ID || '').trim();
31
+ }
32
+
33
+ function dbg(...parts) {
34
+ const timestamp = new Date().toISOString();
35
+ console.log(`[claude-worker ${timestamp}]`, ...parts);
36
+ }
37
+
38
+ async function main() {
39
+ const sdkSessionId = parseSessionIdArg();
40
+ if (!sdkSessionId) {
41
+ console.error('claude-session-worker: missing --session-id');
42
+ process.exit(2);
43
+ }
44
+
45
+ const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
46
+ || path.resolve(__dirname, '..', 'config.json');
47
+ const serverUrl = resolveRelayServerUrl({ configPath });
48
+ const token = loadTokenFromConfig(configPath);
49
+ const cwd = String(process.env.COPILOT_WORKSPACE_ROOT || '').trim() || process.cwd();
50
+ const defaultModel = String(process.env.CLAUDE_RELAY_MODEL || '').trim();
51
+ const pathToClaudeCodeExecutable = String(process.env.CLAUDE_CODE_EXECUTABLE || '').trim();
52
+ // How long background tasks alone may keep the CLI process alive (0 = no
53
+ // limit). Seeded from the environment; refreshed from every delivery payload
54
+ // so the settings slider applies without a worker restart.
55
+ let backgroundTaskTimeoutMs = Number(process.env.CLAUDE_RELAY_BACKGROUND_TASK_TIMEOUT_MS) || 0;
56
+ // Per-conversation auto-compact window (token count, null = Auto); arrives
57
+ // piggybacked on each queue delivery, like the timeouts above.
58
+ let autoCompactWindow = null;
59
+ // Per-conversation thinking control ({enabled: bool|null, display}); same
60
+ // piggyback. enabled null = host default; display defaults to 'summarized'
61
+ // (today's behavior — visible thought bubbles).
62
+ let thinking = resolveDeliveredThinking(null, null);
63
+
64
+ const api = createApiClient({
65
+ serverUrl,
66
+ token,
67
+ getHeaders: () => ({
68
+ 'X-Relay-Process-Pid': String(process.pid),
69
+ 'X-Relay-Parent-Pid': String(process.ppid),
70
+ 'X-Relay-Session-Id': sdkSessionId,
71
+ 'X-Relay-Conversation-Id': sdkSessionId,
72
+ }),
73
+ });
74
+
75
+ const controlPoller = createControlPoller({
76
+ api,
77
+ sdkSessionId,
78
+ abortAckNote: 'claude query aborted',
79
+ // Backgrounded subagents are SDK tasks; their relay run id is the
80
+ // spawning tool_use id, which maps to a stoppable task id. (Late-bound:
81
+ // controls only poll while a turn is live, well after turnRunner exists.)
82
+ onAbortSubagent: (subagentRunId) => turnRunner.stopBackgroundTaskByToolUseId(subagentRunId),
83
+ dbg,
84
+ });
85
+ const turnRunner = createClaudeSessionRunner({
86
+ api,
87
+ sdkSessionId,
88
+ cwd,
89
+ defaultModel,
90
+ controlPoller,
91
+ pathToClaudeCodeExecutable,
92
+ idleShutdownMs: Number(process.env.CLAUDE_RELAY_IDLE_SHUTDOWN_MS) > 0
93
+ ? Number(process.env.CLAUDE_RELAY_IDLE_SHUTDOWN_MS)
94
+ : undefined,
95
+ // 0 disables the watchdog (matching the background-task timeout's
96
+ // 0 = no-limit convention); unset/invalid falls back to the default.
97
+ pendingDeliveredTimeoutMs: (() => {
98
+ const raw = String(process.env.CLAUDE_RELAY_PENDING_DELIVERED_TIMEOUT_MS || '').trim();
99
+ const parsed = Number(raw);
100
+ return raw !== '' && Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
101
+ })(),
102
+ getBackgroundTaskTimeoutMs: () => backgroundTaskTimeoutMs,
103
+ getAutoCompactWindow: () => autoCompactWindow,
104
+ getThinking: () => thinking,
105
+ dbg,
106
+ });
107
+
108
+ let heartbeatTimer = null;
109
+ const heartbeat = createHeartbeatController({
110
+ api,
111
+ pollMs: HEARTBEAT_MS,
112
+ getSessionReady: () => true,
113
+ getHeartbeatTimer: () => heartbeatTimer,
114
+ setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
115
+ getActiveQueueMessageId: () => turnRunner.getActiveQueueMessageId(),
116
+ getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds(),
117
+ });
118
+
119
+ const wsLink = createWorkerWebSocketLink({
120
+ serverUrl,
121
+ token,
122
+ dbg,
123
+ getSessionReady: () => true,
124
+ getSessionId: () => sdkSessionId,
125
+ getPid: () => process.pid,
126
+ onDeliver: async (pending, reason) => {
127
+ dbg('queue.deliver received', `reason=${reason}`, `msgId=${pending?.message?.id || 'none'}`);
128
+ const deliveredTimeout = Number(pending?.settings?.backgroundTaskTimeoutMs);
129
+ if (Number.isFinite(deliveredTimeout) && deliveredTimeout >= 0) {
130
+ backgroundTaskTimeoutMs = deliveredTimeout;
131
+ }
132
+ // Absent (an older relay) leaves the last known value alone; an explicit
133
+ // null is the user choosing Auto and must clear the pin.
134
+ autoCompactWindow = resolveDeliveredAutoCompactWindow(autoCompactWindow, pending?.settings);
135
+ // Same presence semantics as the window: absent keys (an older relay)
136
+ // keep the last known state; an explicit null on thinkingEnabled is the
137
+ // user choosing Host default.
138
+ thinking = resolveDeliveredThinking(thinking, pending?.settings);
139
+ try {
140
+ return await turnRunner.handlePendingPayload(pending);
141
+ } catch (error) {
142
+ dbg('turn handling failed', error?.message || String(error));
143
+ return false;
144
+ }
145
+ },
146
+ onControl: async (control) => {
147
+ const type = String(control?.type || '').trim();
148
+ if (type === 'stop_background_task') {
149
+ const taskId = String(control?.taskId || '').trim();
150
+ dbg('stop background task control', taskId);
151
+ await turnRunner.stopBackgroundTask(taskId);
152
+ return;
153
+ }
154
+ dbg('unknown worker control ignored', type || '(none)');
155
+ },
156
+ });
157
+
158
+ const shutdown = (signal) => {
159
+ dbg(`shutting down (${signal})`);
160
+ try { wsLink.stop(); } catch {}
161
+ try { heartbeat.stopHeartbeat(); } catch {}
162
+ try { controlPoller.stop(); } catch {}
163
+ // Hard-close the persistent CLI process; a worker teardown cannot save
164
+ // its background tasks, and the orphan-notification replay on the next
165
+ // resume reports whatever they left behind.
166
+ try { turnRunner.shutdown({ graceful: false }); } catch {}
167
+ process.exit(0);
168
+ };
169
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
170
+ process.on('SIGINT', () => shutdown('SIGINT'));
171
+ installWorkerCrashGuard({
172
+ api,
173
+ workerName: 'claude-session-worker',
174
+ getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds(),
175
+ onBeforeExit: () => { try { turnRunner.shutdown({ graceful: false }); } catch {} },
176
+ });
177
+
178
+ dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}`);
179
+ heartbeat.startHeartbeat();
180
+ wsLink.start();
181
+ }
182
+
183
+ main().catch((error) => {
184
+ console.error('claude-session-worker fatal:', error?.stack || error?.message || error);
185
+ process.exit(1);
186
+ });
@@ -0,0 +1,189 @@
1
+ import nodeFs from 'node:fs';
2
+ import nodePath from 'node:path';
3
+ import os from 'node:os';
4
+ import { claudeProjectDirSlug, resolveClaudeProjectsRoots } from '../services/claude-session-root-service.mjs';
5
+
6
+ // Keep a resumable Claude session resumable after its workspace root changes.
7
+ //
8
+ // The CLI stores a session as `<configRoot>/projects/<slug(cwd)>/<id>.jsonl`
9
+ // and looks `resume` up in the project directory derived from the CWD of the
10
+ // *current* process — not in the one that wrote the transcript. So changing a
11
+ // conversation's CWD and relaunching the worker leaves every following turn
12
+ // failing with `No conversation found with session ID: <id>`, permanently: the
13
+ // id stays pinned in `runtime_sessions`, and the transcript stays behind in the
14
+ // old workspace's project directory.
15
+ //
16
+ // Before a resuming turn starts, the transcript (and the sibling `<id>/`
17
+ // directory holding subagent and tool-result files) is therefore moved into the
18
+ // project directory for the current CWD.
19
+ //
20
+ // Moved, not copied: a copy left behind in the old directory is resumed again —
21
+ // with its now-stale history — the moment the conversation switches back, which
22
+ // silently drops every turn taken in between.
23
+ //
24
+ // The slug is built from the *real* path. The CLI resolves symlinks before
25
+ // slugifying, so a symlinked workspace root (`/tmp` on macOS, a linked
26
+ // checkout) otherwise targets a directory the CLI never reads.
27
+
28
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
29
+ const MAX_PROJECT_DIRS_SCANNED = 500;
30
+
31
+ function isFile(fs, candidate) {
32
+ try {
33
+ return fs.statSync(candidate).isFile();
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function isDirectory(fs, candidate) {
40
+ try {
41
+ return fs.statSync(candidate).isDirectory();
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ function modifiedAtMs(fs, candidate) {
48
+ try {
49
+ return Number(fs.statSync(candidate).mtimeMs) || 0;
50
+ } catch {
51
+ return 0;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Move `source` to `target`, falling back to copy+delete when the two live on
57
+ * different filesystems (`CLAUDE_CONFIG_DIR` on another mount than `$HOME`).
58
+ */
59
+ function movePath(fs, source, target, { recursive = false } = {}) {
60
+ try {
61
+ fs.renameSync(source, target);
62
+ return;
63
+ } catch (error) {
64
+ if (error?.code !== 'EXDEV') throw error;
65
+ }
66
+ if (recursive) {
67
+ fs.cpSync(source, target, { recursive: true });
68
+ fs.rmSync(source, { recursive: true, force: true });
69
+ return;
70
+ }
71
+ fs.copyFileSync(source, target);
72
+ fs.rmSync(source, { force: true });
73
+ }
74
+
75
+ /**
76
+ * Move the session's sibling directory, merging into an existing target rather
77
+ * than replacing it. The source must always end up gone: a leftover `<id>/`
78
+ * still anchors the session to the old project directory for the relay's
79
+ * Session-root resolver, which would keep the file explorer pointed at it.
80
+ */
81
+ function moveSessionDir(fs, source, target) {
82
+ if (!isDirectory(fs, target)) {
83
+ movePath(fs, source, target, { recursive: true });
84
+ return;
85
+ }
86
+ // Existing target files win — they belong to the session as it runs now.
87
+ fs.cpSync(source, target, { recursive: true, force: false, errorOnExist: false });
88
+ fs.rmSync(source, { recursive: true, force: true });
89
+ }
90
+
91
+ /**
92
+ * Find every project directory that currently holds `<nativeSessionId>.jsonl`,
93
+ * newest first. Bounded and never recursive: one readdir per configured root
94
+ * plus one stat per project directory.
95
+ */
96
+ function findTranscriptSources({ fs, path, roots, nativeSessionId, excludeDir }) {
97
+ const transcriptName = `${nativeSessionId}.jsonl`;
98
+ const matches = [];
99
+ for (const root of roots) {
100
+ let entries = [];
101
+ try {
102
+ entries = fs.readdirSync(root, { withFileTypes: true });
103
+ } catch {
104
+ continue;
105
+ }
106
+ let scanned = 0;
107
+ for (const entry of entries) {
108
+ if (!entry.isDirectory()) continue;
109
+ if (scanned >= MAX_PROJECT_DIRS_SCANNED) break;
110
+ scanned += 1;
111
+ const projectDir = path.join(root, entry.name);
112
+ if (path.resolve(projectDir) === excludeDir) continue;
113
+ const transcriptPath = path.join(projectDir, transcriptName);
114
+ if (!isFile(fs, transcriptPath)) continue;
115
+ matches.push({ projectDir, transcriptPath, modifiedAtMs: modifiedAtMs(fs, transcriptPath) });
116
+ }
117
+ }
118
+ return matches.sort((a, b) => b.modifiedAtMs - a.modifiedAtMs);
119
+ }
120
+
121
+ /**
122
+ * Ensure the session `nativeSessionId` is resumable from `cwd`.
123
+ *
124
+ * Never throws: a relocation that cannot be completed leaves the CLI to report
125
+ * the missing session exactly as it did before, which is strictly better than
126
+ * failing a turn that might not even have needed the transcript.
127
+ *
128
+ * @returns {{ status: 'skipped'|'present'|'moved'|'missing'|'failed', from?: string, to?: string, error?: string }}
129
+ */
130
+ export function relocateClaudeTranscriptForCwd({
131
+ nativeSessionId = '',
132
+ cwd = '',
133
+ fs = nodeFs,
134
+ path = nodePath,
135
+ env = process.env,
136
+ homedir = os.homedir,
137
+ dbg = () => {},
138
+ } = {}) {
139
+ const sessionId = String(nativeSessionId || '').trim();
140
+ const workspaceRoot = String(cwd || '').trim();
141
+ // The id is joined into a filesystem path, so it is validated before any
142
+ // syscall rather than trusted.
143
+ if (!sessionId || !SESSION_ID_PATTERN.test(sessionId) || !workspaceRoot) return { status: 'skipped' };
144
+
145
+ const roots = resolveClaudeProjectsRoots({ env, homedir, path });
146
+ if (!roots.length) return { status: 'skipped' };
147
+
148
+ try {
149
+ let resolvedRoot = workspaceRoot;
150
+ try {
151
+ resolvedRoot = fs.realpathSync(workspaceRoot);
152
+ } catch {
153
+ // A workspace root that no longer exists cannot be resolved; the slug of
154
+ // the literal path is still the best guess.
155
+ }
156
+ // The CLI reads `CLAUDE_CONFIG_DIR` first and falls back to `~/.claude`,
157
+ // which is the order `resolveClaudeProjectsRoots` returns.
158
+ const targetProjectDir = path.join(roots[0], claudeProjectDirSlug(resolvedRoot));
159
+ const targetTranscriptPath = path.join(targetProjectDir, `${sessionId}.jsonl`);
160
+ if (isFile(fs, targetTranscriptPath)) return { status: 'present', to: targetTranscriptPath };
161
+
162
+ const sources = findTranscriptSources({
163
+ fs,
164
+ path,
165
+ roots,
166
+ nativeSessionId: sessionId,
167
+ excludeDir: path.resolve(targetProjectDir),
168
+ });
169
+ if (!sources.length) return { status: 'missing', to: targetTranscriptPath };
170
+
171
+ const source = sources[0];
172
+ fs.mkdirSync(targetProjectDir, { recursive: true });
173
+ movePath(fs, source.transcriptPath, targetTranscriptPath);
174
+
175
+ // The sibling directory is created lazily — only once the session produces
176
+ // subagent or tool-result files — so its absence is normal.
177
+ const sourceSessionDir = path.join(source.projectDir, sessionId);
178
+ if (isDirectory(fs, sourceSessionDir)) {
179
+ moveSessionDir(fs, sourceSessionDir, path.join(targetProjectDir, sessionId));
180
+ }
181
+
182
+ dbg(`relocated claude transcript ${sessionId} from ${source.projectDir} to ${targetProjectDir}`);
183
+ return { status: 'moved', from: source.transcriptPath, to: targetTranscriptPath };
184
+ } catch (error) {
185
+ const message = error?.message || String(error);
186
+ dbg(`claude transcript relocation failed for ${sessionId}: ${message}`);
187
+ return { status: 'failed', error: message };
188
+ }
189
+ }