@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,277 @@
1
+ /**
2
+ * Client-side ACP host services for the Grok worker: the `terminal/*` and
3
+ * `fs/*` request handlers backing the client capabilities advertised in
4
+ * `AcpClient.initialize()`.
5
+ *
6
+ * Advertising a capability is a contract to answer the agent's requests for
7
+ * it — an advertised-but-unanswered request deadlocks the agent's turn: it
8
+ * waits forever on the reply, streaming nothing further, while the worker
9
+ * process stays healthy and heartbeating (the 2026-08-12 stall, where a
10
+ * `terminal/create` went unanswered for 9 minutes until the user hit Stop).
11
+ * If a capability is dropped here, drop it from the initialize payload too.
12
+ */
13
+ import { spawn, spawnSync } from 'node:child_process';
14
+ import fs from 'node:fs/promises';
15
+
16
+ const DEFAULT_OUTPUT_BYTE_LIMIT = 1024 * 1024;
17
+ const MAX_OUTPUT_BYTE_LIMIT = 8 * 1024 * 1024;
18
+ // How long a running terminal's silence still counts as "the agent is
19
+ // waiting on us" for the prompt watchdog. See hasPendingWork.
20
+ const TERMINAL_ACTIVITY_WINDOW_MS = 5 * 60_000;
21
+
22
+ function envArrayToObject(env) {
23
+ const out = {};
24
+ if (!Array.isArray(env)) return out;
25
+ for (const entry of env) {
26
+ const name = String(entry?.name || '').trim();
27
+ if (name) out[name] = String(entry?.value ?? '');
28
+ }
29
+ return out;
30
+ }
31
+
32
+ export function createAcpHostServices({
33
+ cwd = process.cwd(),
34
+ env = process.env,
35
+ platform = process.platform,
36
+ spawnImpl = spawn,
37
+ spawnSyncImpl = spawnSync,
38
+ fsImpl = fs,
39
+ dbg = () => {},
40
+ } = {}) {
41
+ const terminals = new Map();
42
+ let nextTerminalId = 1;
43
+ let inflightRequests = 0;
44
+ let cachedWindowsShell = '';
45
+ let disposed = false;
46
+
47
+ // Grok emits PowerShell-flavored command lines on Windows hosts
48
+ // ("pwd; Get-Location"), so cmd.exe is not an option. Prefer pwsh (7+),
49
+ // fall back to Windows PowerShell.
50
+ function resolveWindowsShell() {
51
+ if (cachedWindowsShell) return cachedWindowsShell;
52
+ try {
53
+ const probe = spawnSyncImpl('pwsh', ['-NoProfile', '-Command', '$null'], {
54
+ windowsHide: true,
55
+ timeout: 10_000,
56
+ stdio: 'ignore',
57
+ });
58
+ cachedWindowsShell = probe && probe.status === 0 ? 'pwsh' : 'powershell';
59
+ } catch {
60
+ cachedWindowsShell = 'powershell';
61
+ }
62
+ return cachedWindowsShell;
63
+ }
64
+
65
+ function buildSpawnPlan(command, args) {
66
+ if (Array.isArray(args) && args.length) {
67
+ return { file: command, args: args.map((value) => String(value)) };
68
+ }
69
+ if (platform === 'win32') {
70
+ return {
71
+ file: resolveWindowsShell(),
72
+ args: ['-NoProfile', '-NonInteractive', '-Command', command],
73
+ };
74
+ }
75
+ const shell = String(env.SHELL || '').trim() || '/bin/sh';
76
+ return { file: shell, args: ['-c', command] };
77
+ }
78
+
79
+ function appendOutput(entry, chunk) {
80
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
81
+ entry.lastActivityAt = Date.now();
82
+ entry.chunks.push(buf);
83
+ entry.byteLength += buf.length;
84
+ // ACP truncates from the start of the output: the most recent bytes win.
85
+ while (entry.byteLength > entry.outputByteLimit && entry.chunks.length) {
86
+ const head = entry.chunks[0];
87
+ const excess = entry.byteLength - entry.outputByteLimit;
88
+ if (head.length <= excess) {
89
+ entry.chunks.shift();
90
+ entry.byteLength -= head.length;
91
+ } else {
92
+ entry.chunks[0] = head.subarray(excess);
93
+ entry.byteLength -= excess;
94
+ }
95
+ entry.truncated = true;
96
+ }
97
+ }
98
+
99
+ function settleExit(entry, exitStatus) {
100
+ if (entry.exitStatus) return;
101
+ entry.exitStatus = exitStatus;
102
+ for (const resolve of entry.exitWaiters.splice(0)) resolve(exitStatus);
103
+ }
104
+
105
+ function getTerminal(terminalId) {
106
+ const entry = terminals.get(String(terminalId || ''));
107
+ if (!entry) throw new Error(`unknown terminalId: ${terminalId}`);
108
+ return entry;
109
+ }
110
+
111
+ async function terminalCreate(params = {}) {
112
+ if (disposed) throw new Error('host services disposed');
113
+ const command = String(params.command || '').trim();
114
+ if (!command) throw new Error('terminal/create requires a command');
115
+ const requestedLimit = Number(params.outputByteLimit);
116
+ const outputByteLimit = Number.isFinite(requestedLimit) && requestedLimit > 0
117
+ ? Math.min(Math.round(requestedLimit), MAX_OUTPUT_BYTE_LIMIT)
118
+ : DEFAULT_OUTPUT_BYTE_LIMIT;
119
+ const plan = buildSpawnPlan(command, params.args);
120
+ const proc = spawnImpl(plan.file, plan.args, {
121
+ cwd: String(params.cwd || '').trim() || cwd,
122
+ env: { ...env, ...envArrayToObject(params.env) },
123
+ stdio: ['ignore', 'pipe', 'pipe'],
124
+ windowsHide: true,
125
+ shell: false,
126
+ });
127
+ const terminalId = `term-${nextTerminalId++}`;
128
+ const entry = {
129
+ terminalId,
130
+ proc,
131
+ chunks: [],
132
+ byteLength: 0,
133
+ truncated: false,
134
+ outputByteLimit,
135
+ exitStatus: null,
136
+ exitWaiters: [],
137
+ lastActivityAt: Date.now(),
138
+ };
139
+ proc.stdout?.on('data', (chunk) => appendOutput(entry, chunk));
140
+ proc.stderr?.on('data', (chunk) => appendOutput(entry, chunk));
141
+ proc.on('error', (error) => {
142
+ // Spawn failures (ENOENT etc.) surface as output + a synthetic exit so
143
+ // the agent's wait_for_exit settles instead of hanging.
144
+ appendOutput(entry, `[terminal spawn error] ${error?.message || String(error)}\n`);
145
+ settleExit(entry, { exitCode: -1, signal: null });
146
+ });
147
+ proc.on('exit', (code, signal) => {
148
+ settleExit(entry, {
149
+ exitCode: Number.isInteger(code) ? code : null,
150
+ signal: signal || null,
151
+ });
152
+ });
153
+ terminals.set(terminalId, entry);
154
+ dbg('terminal/create', terminalId, command.slice(0, 160));
155
+ return { terminalId };
156
+ }
157
+
158
+ function terminalOutput(params = {}) {
159
+ const entry = getTerminal(params.terminalId);
160
+ return {
161
+ output: Buffer.concat(entry.chunks, entry.byteLength).toString('utf8'),
162
+ truncated: entry.truncated,
163
+ ...(entry.exitStatus ? { exitStatus: entry.exitStatus } : {}),
164
+ };
165
+ }
166
+
167
+ function terminalWaitForExit(params = {}) {
168
+ const entry = getTerminal(params.terminalId);
169
+ if (entry.exitStatus) return Promise.resolve(entry.exitStatus);
170
+ return new Promise((resolve) => entry.exitWaiters.push(resolve));
171
+ }
172
+
173
+ function killEntryProcess(entry) {
174
+ if (!entry.proc || entry.exitStatus) return;
175
+ try {
176
+ entry.proc.kill();
177
+ } catch { /* ignore */ }
178
+ if (platform === 'win32' && entry.proc.pid) {
179
+ // proc.kill() only reaches the shell; shells leave grandchildren behind.
180
+ try {
181
+ spawnImpl('taskkill', ['/pid', String(entry.proc.pid), '/t', '/f'], {
182
+ stdio: 'ignore',
183
+ windowsHide: true,
184
+ });
185
+ } catch { /* ignore */ }
186
+ }
187
+ }
188
+
189
+ function terminalKill(params = {}) {
190
+ killEntryProcess(getTerminal(params.terminalId));
191
+ return null;
192
+ }
193
+
194
+ function terminalRelease(params = {}) {
195
+ const entry = terminals.get(String(params?.terminalId || ''));
196
+ if (!entry) return null;
197
+ killEntryProcess(entry);
198
+ terminals.delete(entry.terminalId);
199
+ return null;
200
+ }
201
+
202
+ async function fsReadTextFile(params = {}) {
203
+ const filePath = String(params.path || '').trim();
204
+ if (!filePath) throw new Error('fs/read_text_file requires a path');
205
+ const content = await fsImpl.readFile(filePath, 'utf8');
206
+ const line = Number(params.line);
207
+ const limit = Number(params.limit);
208
+ const hasLine = Number.isFinite(line) && line > 0;
209
+ const hasLimit = Number.isFinite(limit) && limit > 0;
210
+ if (!hasLine && !hasLimit) return { content };
211
+ const lines = content.split('\n');
212
+ const start = hasLine ? line - 1 : 0;
213
+ const count = hasLimit ? limit : lines.length - start;
214
+ return { content: lines.slice(start, start + count).join('\n') };
215
+ }
216
+
217
+ async function fsWriteTextFile(params = {}) {
218
+ const filePath = String(params.path || '').trim();
219
+ if (!filePath) throw new Error('fs/write_text_file requires a path');
220
+ await fsImpl.writeFile(filePath, String(params.content ?? ''), 'utf8');
221
+ return null;
222
+ }
223
+
224
+ const handlers = {
225
+ 'terminal/create': terminalCreate,
226
+ 'terminal/output': terminalOutput,
227
+ 'terminal/wait_for_exit': terminalWaitForExit,
228
+ 'terminal/kill': terminalKill,
229
+ 'terminal/release': terminalRelease,
230
+ 'fs/read_text_file': fsReadTextFile,
231
+ 'fs/write_text_file': fsWriteTextFile,
232
+ };
233
+
234
+ function attach(client) {
235
+ // wait_for_exit is a passive park, not active servicing: counting it as
236
+ // inflight would defer the stall watchdog forever for a process that
237
+ // never exits. Its pending-ness is represented by the unfinished
238
+ // terminal entry below instead.
239
+ const passiveWaits = new Set(['terminal/wait_for_exit']);
240
+ for (const [method, handler] of Object.entries(handlers)) {
241
+ const passive = passiveWaits.has(method);
242
+ client.setRequestHandler(method, async (params) => {
243
+ if (!passive) inflightRequests += 1;
244
+ try {
245
+ return await handler(params);
246
+ } finally {
247
+ if (!passive) inflightRequests -= 1;
248
+ }
249
+ });
250
+ }
251
+ }
252
+
253
+ /**
254
+ * True while the agent is legitimately waiting on us: a handler request is
255
+ * being serviced, or a terminal process is running AND recently active. A
256
+ * long-quiet background command (a dev server, a hung build) stops
257
+ * deferring the stall watchdog after the activity window — otherwise one
258
+ * `npm run dev` made every turn immune to stall detection forever.
259
+ */
260
+ function hasPendingWork() {
261
+ if (inflightRequests > 0) return true;
262
+ const now = Date.now();
263
+ for (const entry of terminals.values()) {
264
+ if (entry.exitStatus) continue;
265
+ if (now - entry.lastActivityAt <= TERMINAL_ACTIVITY_WINDOW_MS) return true;
266
+ }
267
+ return false;
268
+ }
269
+
270
+ function disposeAll() {
271
+ disposed = true;
272
+ for (const entry of terminals.values()) killEntryProcess(entry);
273
+ terminals.clear();
274
+ }
275
+
276
+ return { attach, hasPendingWork, disposeAll, handlers };
277
+ }
@@ -0,0 +1,77 @@
1
+ import { resolveFallbackContextLimitTokens } from '../../shared/context-window-fallbacks.mjs';
2
+ /**
3
+ * Maps a normalized Grok turn usage blob (per-prompt `_meta` tokens from
4
+ * `normalizeGrokTurnUsage`) onto the payload shape the relay already persists
5
+ * for Claude/Cursor sessions, so the existing context view renders it
6
+ * unchanged. Mirrors `buildCursorContextUsage`.
7
+ *
8
+ * ACP reports no per-category breakdown, so `categories` is always empty.
9
+ */
10
+
11
+ // Best-effort fallbacks when ACP model discovery reports no window size come
12
+ // from the shared static table (shared/context-window-fallbacks.mjs), which
13
+ // exists precisely so per-provider copies cannot drift. Unknown models get no
14
+ // fill metric (token totals only, like Cursor's partial state).
15
+ export function resolveGrokContextWindow(model = '', contextWindowsByModel = {}) {
16
+ const key = String(model || '').trim().toLowerCase();
17
+ if (!key) return null;
18
+ const discovered = Number(contextWindowsByModel?.[key]);
19
+ if (Number.isFinite(discovered) && discovered > 0) return Math.round(discovered);
20
+ return resolveFallbackContextLimitTokens(key);
21
+ }
22
+
23
+ function tokenCount(value) {
24
+ if (value === null || value === undefined || value === '') return null;
25
+ const n = Number(value);
26
+ return Number.isFinite(n) && n >= 0 ? Math.round(n) : null;
27
+ }
28
+
29
+ export function buildGrokContextUsage({ usage, model = '', contextWindow = null } = {}) {
30
+ if (!usage || typeof usage !== 'object') return null;
31
+
32
+ const inputTokens = tokenCount(usage.inputTokens);
33
+ const outputTokens = tokenCount(usage.outputTokens);
34
+ const cacheReadTokens = tokenCount(usage.cachedReadTokens);
35
+ const cacheWriteTokens = tokenCount(usage.cacheCreationTokens);
36
+ if (inputTokens === null && outputTokens === null
37
+ && cacheReadTokens === null && cacheWriteTokens === null) {
38
+ return null;
39
+ }
40
+
41
+ // Context occupancy approximates what the next request re-sends: fresh
42
+ // input + cache reads + cache writes + the reply. The blob's `totalTokens`
43
+ // may be cumulative across the turn's model calls, so it is not used.
44
+ const totalTokens = (inputTokens || 0) + (cacheReadTokens || 0)
45
+ + (cacheWriteTokens || 0) + (outputTokens || 0);
46
+ const maxTokens = (typeof contextWindow === 'number' && Number.isFinite(contextWindow)
47
+ && contextWindow > 0)
48
+ ? Math.round(contextWindow)
49
+ : null;
50
+
51
+ const contextUsage = {
52
+ model,
53
+ totalTokens,
54
+ ...(maxTokens !== null
55
+ ? { maxTokens, percentage: Math.round((totalTokens / maxTokens) * 10000) / 100 }
56
+ : {}),
57
+ categories: [],
58
+ apiUsage: {
59
+ input_tokens: inputTokens || 0,
60
+ output_tokens: outputTokens || 0,
61
+ cache_read_input_tokens: cacheReadTokens || 0,
62
+ cache_creation_input_tokens: cacheWriteTokens || 0,
63
+ },
64
+ };
65
+
66
+ const modelUsage = {
67
+ [model]: {
68
+ ...(maxTokens !== null ? { contextWindow: maxTokens } : {}),
69
+ inputTokens: inputTokens || 0,
70
+ outputTokens: outputTokens || 0,
71
+ cacheReadInputTokens: cacheReadTokens || 0,
72
+ cacheCreationInputTokens: cacheWriteTokens || 0,
73
+ },
74
+ };
75
+
76
+ return { contextUsage, modelUsage };
77
+ }
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Thin adapter around the Grok CLI ACP surface. One long-lived agent process
3
+ * per worker; sessions are created/loaded and prompts streamed via AcpClient.
4
+ */
5
+ import { AcpClient, extractGrokModelsFromInitialize, pickAllowOnceOptionId } from './acp-client.mjs';
6
+ import { createAcpHostServices } from './acp-host-services.mjs';
7
+ import { createSdkMessageNormalizer } from './sdk-message-normalizer.mjs';
8
+
9
+ export { extractGrokModelsFromInitialize };
10
+
11
+ export function classifyGrokError(error) {
12
+ const message = String(error?.message || error || '').trim();
13
+ const lower = message.toLowerCase();
14
+ // No bare 'auth' token: it would match unrelated words ("author…") in
15
+ // arbitrary agent error text and misclassify them as login failures.
16
+ if (
17
+ lower.includes('authentication')
18
+ || lower.includes('not logged in')
19
+ || lower.includes('not authenticated')
20
+ || lower.includes('unauthorized')
21
+ || lower.includes('api key')
22
+ ) {
23
+ return {
24
+ code: 'grok.authentication_failed',
25
+ // Points at the relay's own UI, which renders a Sign in button for this
26
+ // stable code (relay-error-ctas.mjs); the host-shell advice stays as the
27
+ // fallback for anyone reading this outside a browser.
28
+ message: 'Grok authentication failed. Sign in from Settings → Providers → Grok, '
29
+ + 'or run `grok login` on the relay host / set XAI_API_KEY.',
30
+ isAuth: true,
31
+ isBusy: false,
32
+ };
33
+ }
34
+ if (lower.includes('already has an active') || lower.includes('busy')) {
35
+ return {
36
+ code: 'grok.agent_busy',
37
+ message: message || 'Grok agent is busy',
38
+ isAuth: false,
39
+ isBusy: true,
40
+ };
41
+ }
42
+ // Watchdog trips from AcpClient.sessionPrompt (inactivity or turn ceiling).
43
+ if (lower.includes('turn stalled') || lower.includes('turn exceeded')) {
44
+ return {
45
+ code: 'grok.turn-stalled',
46
+ message: message || 'Grok turn stalled',
47
+ isAuth: false,
48
+ isBusy: false,
49
+ isStalled: true,
50
+ };
51
+ }
52
+ if (lower.includes('enoent') || lower.includes('not found') || lower.includes('spawn')) {
53
+ return {
54
+ code: 'grok.cli_missing',
55
+ // The dead end this plan exists for: the only fix used to be a shell on
56
+ // the host. The relay now installs it, and this stable code renders an
57
+ // Install button (relay-error-ctas.mjs).
58
+ message: 'Grok CLI was not found on PATH. Install it from Settings → Providers → Grok.',
59
+ isAuth: false,
60
+ isBusy: false,
61
+ };
62
+ }
63
+ return {
64
+ code: 'grok.turn-error',
65
+ message: message || 'Grok turn failed',
66
+ isAuth: false,
67
+ isBusy: false,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Create or resume a Grok ACP session handle for one conversation worker.
73
+ */
74
+ export async function createGrokAgentHandle({
75
+ command = 'grok',
76
+ args = ['agent', '--no-leader', 'stdio'],
77
+ cwd = process.cwd(),
78
+ env = process.env,
79
+ alwaysApprove = true,
80
+ nativeSessionId = '',
81
+ model = '',
82
+ AcpClientImpl = AcpClient,
83
+ createHostServicesImpl = createAcpHostServices,
84
+ dbg = () => {},
85
+ } = {}) {
86
+ const client = new AcpClientImpl({
87
+ command,
88
+ args,
89
+ cwd,
90
+ env,
91
+ alwaysApprove,
92
+ });
93
+
94
+ // Without any 'error' listener, a spawn failure (ENOENT when the Grok CLI
95
+ // is not installed) would make the emitter throw ERR_UNHANDLED_ERROR and
96
+ // crash the process. The pending-request rejection already surfaces the
97
+ // failure to the awaiting caller, so the listener only needs to exist.
98
+ client.on('error', () => {});
99
+
100
+ client.on('stderr', (text) => {
101
+ const line = String(text || '').trim();
102
+ if (line) dbg('grok stderr', line.slice(0, 400));
103
+ });
104
+
105
+ // The terminal/fs handlers backing the capabilities initialize() advertises.
106
+ // They must be attached before the first prompt, or the agent's first shell
107
+ // command deadlocks the turn waiting on terminal/create.
108
+ const hostServices = createHostServicesImpl({ cwd, env, dbg });
109
+ hostServices.attach(client);
110
+
111
+ await client.initialize();
112
+ const discovered = extractGrokModelsFromInitialize(client.initializeResult);
113
+
114
+ let sessionId = '';
115
+ let resumeFailed = false;
116
+ const resumeId = String(nativeSessionId || '').trim();
117
+ if (resumeId) {
118
+ // Attempting session/load against an agent that never advertised the
119
+ // capability is a guaranteed failure; either way a fresh session is a
120
+ // silent history loss the caller must be able to surface.
121
+ const capabilities = client.initializeResult?.agentCapabilities
122
+ || client.initializeResult?.capabilities
123
+ || {};
124
+ const supportsLoad = capabilities.loadSession === true || capabilities.load_session === true;
125
+ if (!supportsLoad) {
126
+ resumeFailed = true;
127
+ dbg('grok agent does not advertise loadSession; starting fresh');
128
+ } else {
129
+ try {
130
+ const loaded = await client.sessionLoad(resumeId, cwd);
131
+ sessionId = String(loaded?.sessionId || loaded?.session_id || resumeId).trim();
132
+ } catch (error) {
133
+ resumeFailed = true;
134
+ dbg('grok session/load failed, creating new session', error?.message || String(error));
135
+ }
136
+ }
137
+ }
138
+ if (!sessionId) {
139
+ const extra = {};
140
+ const modelId = String(model || '').trim();
141
+ if (modelId) {
142
+ // Best-effort: some Grok builds accept model via _meta on session/new.
143
+ extra._meta = { modelId };
144
+ }
145
+ const created = await client.sessionNew(cwd, extra);
146
+ sessionId = String(created?.sessionId || created?.session_id || '').trim();
147
+ }
148
+ if (!sessionId) {
149
+ hostServices.disposeAll();
150
+ await client.dispose();
151
+ throw new Error('Grok session/new did not return sessionId');
152
+ }
153
+
154
+ return {
155
+ client,
156
+ sessionId,
157
+ model: String(model || discovered.defaultModel || '').trim(),
158
+ discovered,
159
+ hostServices,
160
+ resumeFailed,
161
+ async close() {
162
+ hostServices.disposeAll();
163
+ await client.dispose();
164
+ },
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Stream-friendly turn starter used by the turn runner. Yields the same
170
+ * channel actions the normalizer produces, draining pending actions interleaved
171
+ * by polling a queue that the ACP update handler fills.
172
+ */
173
+ export function startGrokTurn({
174
+ handle,
175
+ text = '',
176
+ reasoningEffort = '',
177
+ abortSignal = null,
178
+ watchdog = null,
179
+ createNormalizerImpl = createSdkMessageNormalizer,
180
+ dbg = () => {},
181
+ } = {}) {
182
+ if (!handle?.client || !handle?.sessionId) {
183
+ throw new Error('missing grok agent handle');
184
+ }
185
+
186
+ const normalizer = createNormalizerImpl();
187
+ const queue = [];
188
+ let done = false;
189
+ let settleError = null;
190
+ let resolveWait = null;
191
+
192
+ function wake() {
193
+ if (resolveWait) {
194
+ const r = resolveWait;
195
+ resolveWait = null;
196
+ r();
197
+ }
198
+ }
199
+
200
+ function enqueue(action) {
201
+ queue.push(action);
202
+ wake();
203
+ }
204
+
205
+ enqueue({
206
+ channel: 'init',
207
+ payload: {
208
+ sessionId: handle.sessionId,
209
+ model: handle.model || '',
210
+ },
211
+ });
212
+
213
+ let cancelled = false;
214
+ const onAbort = () => {
215
+ cancelled = true;
216
+ try {
217
+ handle.client.sessionCancel(handle.sessionId);
218
+ } catch (error) {
219
+ dbg('session/cancel failed', error?.message || String(error));
220
+ }
221
+ };
222
+ if (abortSignal) {
223
+ if (abortSignal.aborted) onAbort();
224
+ else abortSignal.addEventListener('abort', onAbort, { once: true });
225
+ }
226
+
227
+ const onPermission = (msg) => {
228
+ // Prefer a one-shot allow: a blanket "allow always" would grant more than
229
+ // the relay's per-request approval parity intends.
230
+ const optionId = pickAllowOnceOptionId(msg?.params?.options);
231
+ try {
232
+ handle.client.respond(msg.id, {
233
+ outcome: { outcome: 'selected', optionId },
234
+ });
235
+ } catch (error) {
236
+ dbg('permission respond failed', error?.message || String(error));
237
+ }
238
+ };
239
+ handle.client.on('permission', onPermission);
240
+
241
+ // Best-effort per-turn reasoning effort: forwarded on the prompt `_meta`
242
+ // the same way the model is passed on session/new. Unknown _meta keys are
243
+ // ignored by agent builds that do not support them.
244
+ const effort = String(reasoningEffort || '').trim().toLowerCase();
245
+ const promptExtra = effort && effort !== 'none'
246
+ ? { _meta: { reasoningEffort: effort } }
247
+ : {};
248
+
249
+ const promptPromise = handle.client.sessionPrompt(
250
+ handle.sessionId,
251
+ [{ type: 'text', text: String(text || '') }],
252
+ (update) => {
253
+ for (const action of normalizer.normalizeAcpUpdate(update)) {
254
+ enqueue(action);
255
+ }
256
+ },
257
+ promptExtra,
258
+ {
259
+ // A running client-hosted terminal (or an in-flight terminal/fs request)
260
+ // means the agent is waiting on us, not stalled.
261
+ hasPendingWork: () => handle.hostServices?.hasPendingWork?.() === true,
262
+ ...(watchdog || {}),
263
+ },
264
+ ).then((promptResult) => {
265
+ const stopReason = String(promptResult?.stopReason || promptResult?.stop_reason || '').trim();
266
+ const isError = stopReason.toLowerCase() === 'error' || promptResult?.isError === true;
267
+ // Per-prompt usage lives on the result `_meta` (tokens + costUsdTicks).
268
+ // Prefer that over any mid-stream usage_update the normalizer may have seen.
269
+ const meta = promptResult?._meta && typeof promptResult._meta === 'object'
270
+ ? promptResult._meta
271
+ : null;
272
+ const usage = meta
273
+ ? {
274
+ ...meta,
275
+ usage: meta.usage,
276
+ modelId: meta.modelId || handle.model || '',
277
+ }
278
+ : null;
279
+ for (const action of normalizer.finalizeResult({
280
+ stopReason: cancelled ? 'cancelled' : stopReason,
281
+ text: normalizer.finalStreamText(),
282
+ isError: cancelled ? false : isError,
283
+ errorMessage: isError ? String(promptResult?.error || promptResult?.message || stopReason) : '',
284
+ model: String(meta?.modelId || handle.model || '').trim(),
285
+ usage,
286
+ })) {
287
+ enqueue(action);
288
+ }
289
+ }).catch((error) => {
290
+ if (cancelled || abortSignal?.aborted) {
291
+ for (const action of normalizer.finalizeResult({
292
+ stopReason: 'cancelled',
293
+ text: normalizer.finalStreamText(),
294
+ })) {
295
+ enqueue(action);
296
+ }
297
+ return;
298
+ }
299
+ settleError = error;
300
+ }).finally(() => {
301
+ done = true;
302
+ wake();
303
+ handle.client.off('permission', onPermission);
304
+ if (abortSignal) {
305
+ try {
306
+ abortSignal.removeEventListener('abort', onAbort);
307
+ } catch {
308
+ /* ignore */
309
+ }
310
+ }
311
+ });
312
+
313
+ async function* iterator() {
314
+ while (true) {
315
+ while (queue.length) {
316
+ yield queue.shift();
317
+ }
318
+ if (done) {
319
+ if (settleError) throw settleError;
320
+ return;
321
+ }
322
+ await new Promise((resolve) => {
323
+ resolveWait = resolve;
324
+ });
325
+ }
326
+ }
327
+
328
+ const iter = iterator();
329
+ iter.cancel = async () => {
330
+ onAbort();
331
+ await promptPromise.catch(() => {});
332
+ };
333
+ return iter;
334
+ }