@hellcoder/companion 0.96.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 (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,1343 @@
1
+ import { vi } from "vitest";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+
6
+ // ─── Hoisted mocks ──────────────────────────────────────────────────────────
7
+
8
+ // Mock randomUUID so session IDs are deterministic
9
+ vi.mock("node:crypto", () => ({ randomUUID: () => "test-session-id" }));
10
+
11
+ // Mock path-resolver for binary resolution
12
+ const mockResolveBinary = vi.hoisted(() => vi.fn((_name: string): string | null => "/usr/bin/claude"));
13
+ const mockGetEnrichedPath = vi.hoisted(() => vi.fn(() => "/usr/bin:/usr/local/bin"));
14
+ vi.mock("./path-resolver.js", () => ({ resolveBinary: mockResolveBinary, getEnrichedPath: mockGetEnrichedPath }));
15
+
16
+ // Mock container-manager for container validation in relaunch
17
+ const mockIsContainerAlive = vi.hoisted(() => vi.fn((): "running" | "stopped" | "missing" => "running"));
18
+ const mockHasBinaryInContainer = vi.hoisted(() => vi.fn((): boolean => true));
19
+ const mockStartContainer = vi.hoisted(() => vi.fn());
20
+ const mockGetContainerById = vi.hoisted(() => vi.fn((_containerId: string) => undefined as any));
21
+ vi.mock("./container-manager.js", () => ({
22
+ containerManager: {
23
+ isContainerAlive: mockIsContainerAlive,
24
+ hasBinaryInContainer: mockHasBinaryInContainer,
25
+ startContainer: mockStartContainer,
26
+ getContainerById: mockGetContainerById,
27
+ },
28
+ }));
29
+
30
+ // Mock fs operations for worktree guardrails (CLAUDE.md in .claude dirs)
31
+ const mockMkdirSync = vi.hoisted(() => vi.fn());
32
+ const mockExistsSync = vi.hoisted(() => vi.fn((..._args: any[]) => false));
33
+ const mockReadFileSync = vi.hoisted(() => vi.fn((..._args: any[]) => ""));
34
+ const mockWriteFileSync = vi.hoisted(() => vi.fn());
35
+ const isMockedPath = vi.hoisted(() => (path: string): boolean => {
36
+ return path.includes(".claude") || path.startsWith("/tmp/worktrees/") || path.startsWith("/tmp/main-repo");
37
+ });
38
+
39
+ vi.mock("node:fs", async (importOriginal) => {
40
+ const actual = (await importOriginal()) as any;
41
+ return {
42
+ ...actual,
43
+ mkdirSync: (...args: any[]) => {
44
+ if (typeof args[0] === "string" && isMockedPath(args[0])) {
45
+ return mockMkdirSync(...args);
46
+ }
47
+ return actual.mkdirSync(...args);
48
+ },
49
+ existsSync: (...args: any[]) => {
50
+ if (typeof args[0] === "string" && isMockedPath(args[0])) {
51
+ return mockExistsSync(...args);
52
+ }
53
+ return actual.existsSync(...args);
54
+ },
55
+ readFileSync: (...args: any[]) => {
56
+ if (typeof args[0] === "string" && isMockedPath(args[0])) {
57
+ return mockReadFileSync(...args);
58
+ }
59
+ return actual.readFileSync(...args);
60
+ },
61
+ writeFileSync: (...args: any[]) => {
62
+ if (typeof args[0] === "string" && isMockedPath(args[0])) {
63
+ return mockWriteFileSync(...args);
64
+ }
65
+ return actual.writeFileSync(...args);
66
+ },
67
+ };
68
+ });
69
+
70
+ // ─── Imports (after mocks) ───────────────────────────────────────────────────
71
+
72
+ import { SessionStore } from "./session-store.js";
73
+ import { CliLauncher } from "./cli-launcher.js";
74
+ import { companionBus } from "./event-bus.js";
75
+
76
+ // ─── Bun.spawn mock ─────────────────────────────────────────────────────────
77
+
78
+ let exitResolve: (code: number) => void;
79
+
80
+ function createMockProc(pid = 12345) {
81
+ let resolve: (code: number) => void;
82
+ const exitedPromise = new Promise<number>((r) => {
83
+ resolve = r;
84
+ });
85
+ exitResolve = resolve!;
86
+ return {
87
+ pid,
88
+ kill: vi.fn(),
89
+ exited: exitedPromise,
90
+ stdout: null,
91
+ stderr: null,
92
+ };
93
+ }
94
+
95
+ function createMockCodexProc(pid = 12345) {
96
+ let resolve: (code: number) => void;
97
+ const exitedPromise = new Promise<number>((r) => {
98
+ resolve = r;
99
+ });
100
+ exitResolve = resolve!;
101
+ return {
102
+ pid,
103
+ kill: vi.fn(),
104
+ exited: exitedPromise,
105
+ stdin: new WritableStream<Uint8Array>(),
106
+ stdout: new ReadableStream<Uint8Array>(),
107
+ stderr: new ReadableStream<Uint8Array>(),
108
+ };
109
+ }
110
+
111
+ function createPendingCodexWsProxyProc(pid = 12345) {
112
+ let resolve: (code: number) => void;
113
+ const exitedPromise = new Promise<number>((r) => {
114
+ resolve = r;
115
+ });
116
+
117
+ // Keep stdout open so CodexAdapter can wait for JSON-RPC responses without
118
+ // immediately failing initialization in tests that only care about launcher lifecycle.
119
+ const stdout = new ReadableStream<Uint8Array>({ start() {} });
120
+ const stderr = new ReadableStream<Uint8Array>({ start() {} });
121
+
122
+ return {
123
+ proc: {
124
+ pid,
125
+ kill: vi.fn(),
126
+ exited: exitedPromise,
127
+ stdin: new WritableStream<Uint8Array>(),
128
+ stdout,
129
+ stderr,
130
+ },
131
+ resolveExit: resolve!,
132
+ };
133
+ }
134
+
135
+ const mockSpawn = vi.fn();
136
+ const mockListen = vi.hoisted(() => vi.fn(() => ({ stop: vi.fn() })));
137
+ vi.stubGlobal("Bun", { spawn: mockSpawn, listen: mockListen });
138
+
139
+ // ─── Test setup ──────────────────────────────────────────────────────────────
140
+
141
+ let tempDir: string;
142
+ let store: SessionStore;
143
+ let launcher: CliLauncher;
144
+
145
+ beforeEach(() => {
146
+ vi.clearAllMocks();
147
+ companionBus.clear();
148
+ delete process.env.COMPANION_CONTAINER_SDK_HOST;
149
+ delete process.env.COMPANION_FORCE_BYPASS_IN_CONTAINER;
150
+ // Default to stdio for most tests; WS launcher behavior is covered explicitly below.
151
+ process.env.COMPANION_CODEX_TRANSPORT = "stdio";
152
+ tempDir = mkdtempSync(join(tmpdir(), "launcher-test-"));
153
+ store = new SessionStore(tempDir);
154
+ launcher = new CliLauncher(3456);
155
+ launcher.setStore(store);
156
+ mockSpawn.mockReturnValue(createMockProc());
157
+ mockListen.mockImplementation(() => ({ stop: vi.fn() }));
158
+ mockResolveBinary.mockReturnValue("/usr/bin/claude");
159
+ mockGetContainerById.mockReturnValue(undefined);
160
+ });
161
+
162
+ afterEach(() => {
163
+ delete process.env.COMPANION_CODEX_TRANSPORT;
164
+ delete process.env.COMPANION_CODEX_WS_CONNECT_TIMEOUT_MS;
165
+ delete process.env.COMPANION_CODEX_PONG_TIMEOUT_MS;
166
+ rmSync(tempDir, { recursive: true, force: true });
167
+ });
168
+
169
+ // ─── launch ──────────────────────────────────────────────────────────────────
170
+
171
+ describe("launch", () => {
172
+ it("creates a session with a UUID and starting state", () => {
173
+ const info = launcher.launch({ cwd: "/tmp/project" });
174
+
175
+ expect(info.sessionId).toBe("test-session-id");
176
+ expect(info.state).toBe("starting");
177
+ expect(info.cwd).toBe("/tmp/project");
178
+ expect(info.createdAt).toBeGreaterThan(0);
179
+ });
180
+
181
+ it("spawns CLI with correct --sdk-url and flags", () => {
182
+ launcher.launch({ cwd: "/tmp/project" });
183
+
184
+ expect(mockSpawn).toHaveBeenCalledOnce();
185
+ const [cmdAndArgs, options] = mockSpawn.mock.calls[0];
186
+
187
+ // Binary should be resolved via execSync
188
+ expect(cmdAndArgs[0]).toBe("/usr/bin/claude");
189
+
190
+ // Core required flags
191
+ expect(cmdAndArgs).toContain("--sdk-url");
192
+ expect(cmdAndArgs).toContain("ws://localhost:3456/ws/cli/test-session-id");
193
+ expect(cmdAndArgs).toContain("--print");
194
+ expect(cmdAndArgs).toContain("--output-format");
195
+ expect(cmdAndArgs).toContain("stream-json");
196
+ expect(cmdAndArgs).toContain("--input-format");
197
+ expect(cmdAndArgs).toContain("--include-partial-messages");
198
+ expect(cmdAndArgs).toContain("--verbose");
199
+
200
+ // Headless prompt
201
+ expect(cmdAndArgs).toContain("-p");
202
+ expect(cmdAndArgs).toContain("");
203
+
204
+ // Spawn options
205
+ expect(options.cwd).toBe("/tmp/project");
206
+ expect(options.stdout).toBe("pipe");
207
+ expect(options.stderr).toBe("pipe");
208
+ });
209
+
210
+ it("passes --model when provided", () => {
211
+ launcher.launch({ model: "claude-opus-4-20250514", cwd: "/tmp" });
212
+
213
+ const [cmdAndArgs] = mockSpawn.mock.calls[0];
214
+ const modelIdx = cmdAndArgs.indexOf("--model");
215
+ expect(modelIdx).toBeGreaterThan(-1);
216
+ expect(cmdAndArgs[modelIdx + 1]).toBe("claude-opus-4-20250514");
217
+ });
218
+
219
+ it("passes --permission-mode when provided", () => {
220
+ // Allow bypassPermissions through even when tests run as root
221
+ process.env.COMPANION_FORCE_BYPASS_AS_ROOT = "1";
222
+ try {
223
+ launcher.launch({ permissionMode: "bypassPermissions", cwd: "/tmp" });
224
+
225
+ const [cmdAndArgs] = mockSpawn.mock.calls[0];
226
+ const modeIdx = cmdAndArgs.indexOf("--permission-mode");
227
+ expect(modeIdx).toBeGreaterThan(-1);
228
+ expect(cmdAndArgs[modeIdx + 1]).toBe("bypassPermissions");
229
+ } finally {
230
+ delete process.env.COMPANION_FORCE_BYPASS_AS_ROOT;
231
+ }
232
+ });
233
+
234
+ it("downgrades bypassPermissions to acceptEdits for containerized Claude sessions", () => {
235
+ launcher.launch({
236
+ cwd: "/tmp/project",
237
+ permissionMode: "bypassPermissions",
238
+ containerId: "abc123def456",
239
+ containerName: "companion-test",
240
+ });
241
+
242
+ const [cmdAndArgs] = mockSpawn.mock.calls[0];
243
+ // With bash -lc wrapping, CLI args are in the last element as a single string
244
+ const bashCmd = cmdAndArgs[cmdAndArgs.length - 1];
245
+ expect(bashCmd).toContain("--permission-mode");
246
+ expect(bashCmd).toContain("acceptEdits");
247
+ expect(bashCmd).not.toContain("bypassPermissions");
248
+ });
249
+
250
+ it("downgrades bypassPermissions to acceptEdits when host launcher runs as root", () => {
251
+ const originalGetuid = process.getuid;
252
+ Object.defineProperty(process, "getuid", {
253
+ value: () => 0,
254
+ configurable: true,
255
+ });
256
+
257
+ try {
258
+ launcher.launch({
259
+ cwd: "/tmp/project",
260
+ permissionMode: "bypassPermissions",
261
+ });
262
+
263
+ const [cmdAndArgs] = mockSpawn.mock.calls[0];
264
+ const modeIdx = cmdAndArgs.indexOf("--permission-mode");
265
+ expect(modeIdx).toBeGreaterThan(-1);
266
+ expect(cmdAndArgs[modeIdx + 1]).toBe("acceptEdits");
267
+ } finally {
268
+ Object.defineProperty(process, "getuid", {
269
+ value: originalGetuid,
270
+ configurable: true,
271
+ });
272
+ }
273
+ });
274
+
275
+ it("uses COMPANION_CONTAINER_SDK_HOST for containerized sdk-url when set", () => {
276
+ process.env.COMPANION_CONTAINER_SDK_HOST = "172.17.0.1";
277
+ launcher.launch({
278
+ cwd: "/tmp/project",
279
+ containerId: "abc123def456",
280
+ containerName: "companion-test",
281
+ });
282
+
283
+ const [cmdAndArgs] = mockSpawn.mock.calls[0];
284
+ // With bash -lc wrapping, CLI args are in the last element as a single string
285
+ const bashCmd = cmdAndArgs[cmdAndArgs.length - 1];
286
+ expect(bashCmd).toContain("--sdk-url");
287
+ expect(bashCmd).toContain("ws://172.17.0.1:3456/ws/cli/test-session-id");
288
+ });
289
+
290
+ it("passes --allowedTools for each tool", () => {
291
+ launcher.launch({
292
+ allowedTools: ["Read", "Write", "Bash"],
293
+ cwd: "/tmp",
294
+ });
295
+
296
+ const [cmdAndArgs] = mockSpawn.mock.calls[0];
297
+ // Each tool gets its own --allowedTools flag
298
+ const toolFlags = cmdAndArgs.reduce(
299
+ (acc: string[], arg: string, i: number) => {
300
+ if (arg === "--allowedTools") acc.push(cmdAndArgs[i + 1]);
301
+ return acc;
302
+ },
303
+ [],
304
+ );
305
+ expect(toolFlags).toEqual(["Read", "Write", "Bash"]);
306
+ });
307
+
308
+ it("passes branching flags when resumeSessionAt/forkSession are provided", () => {
309
+ // These flags enable starting a new branch of work from a prior session point.
310
+ launcher.launch({
311
+ cwd: "/tmp",
312
+ resumeSessionAt: "prior-session-123",
313
+ forkSession: true,
314
+ });
315
+
316
+ const [cmdAndArgs] = mockSpawn.mock.calls[0];
317
+ const resumeAtIdx = cmdAndArgs.indexOf("--resume-session-at");
318
+ expect(resumeAtIdx).toBeGreaterThan(-1);
319
+ expect(cmdAndArgs[resumeAtIdx + 1]).toBe("prior-session-123");
320
+ expect(cmdAndArgs).toContain("--fork-session");
321
+ });
322
+
323
+ it("resolves binary path via resolveBinary when not absolute", () => {
324
+ mockResolveBinary.mockReturnValue("/usr/local/bin/claude-dev");
325
+ launcher.launch({ claudeBinary: "claude-dev", cwd: "/tmp" });
326
+
327
+ expect(mockResolveBinary).toHaveBeenCalledWith("claude-dev");
328
+ const [cmdAndArgs] = mockSpawn.mock.calls[0];
329
+ expect(cmdAndArgs[0]).toBe("/usr/local/bin/claude-dev");
330
+ });
331
+
332
+ it("passes absolute binary path directly to resolveBinary", () => {
333
+ mockResolveBinary.mockReturnValue("/opt/bin/claude");
334
+ launcher.launch({
335
+ claudeBinary: "/opt/bin/claude",
336
+ cwd: "/tmp",
337
+ });
338
+
339
+ expect(mockResolveBinary).toHaveBeenCalledWith("/opt/bin/claude");
340
+ const [cmdAndArgs] = mockSpawn.mock.calls[0];
341
+ expect(cmdAndArgs[0]).toBe("/opt/bin/claude");
342
+ });
343
+
344
+ it("sets state=exited and exitCode=127 when claude binary not found", () => {
345
+ mockResolveBinary.mockReturnValue(null);
346
+
347
+ const info = launcher.launch({ cwd: "/tmp" });
348
+
349
+ expect(info.state).toBe("exited");
350
+ expect(info.exitCode).toBe(127);
351
+ expect(mockSpawn).not.toHaveBeenCalled();
352
+ });
353
+
354
+ it("stores container metadata when containerId provided", () => {
355
+ const info = launcher.launch({
356
+ cwd: "/tmp/project",
357
+ containerId: "abc123def456",
358
+ containerName: "companion-session-1",
359
+ containerImage: "ubuntu:22.04",
360
+ });
361
+
362
+ expect(info.containerId).toBe("abc123def456");
363
+ expect(info.containerName).toBe("companion-session-1");
364
+ expect(info.containerImage).toBe("ubuntu:22.04");
365
+ expect(info.containerCwd).toBe("/workspace");
366
+ });
367
+
368
+ it("stores explicit containerCwd when provided", () => {
369
+ mockSpawn.mockReturnValueOnce(createMockCodexProc());
370
+ const info = launcher.launch({
371
+ cwd: "/tmp/project",
372
+ backendType: "codex",
373
+ containerId: "abc123def456",
374
+ containerName: "companion-session-1",
375
+ containerImage: "ubuntu:22.04",
376
+ containerCwd: "/workspace/repo",
377
+ });
378
+
379
+ expect(info.containerCwd).toBe("/workspace/repo");
380
+ });
381
+
382
+ it("uses docker exec -i with bash -lc for containerized Claude sessions", () => {
383
+ // bash -lc ensures ~/.bashrc is sourced so nvm-installed CLIs are on PATH
384
+ launcher.launch({
385
+ cwd: "/tmp/project",
386
+ containerId: "abc123def456",
387
+ containerName: "companion-session-1",
388
+ });
389
+
390
+ const [cmdAndArgs] = mockSpawn.mock.calls[0];
391
+ expect(cmdAndArgs[0]).toBe("docker");
392
+ expect(cmdAndArgs[1]).toBe("exec");
393
+ expect(cmdAndArgs[2]).toBe("-i");
394
+ // Should wrap the CLI command in bash -lc for login shell PATH
395
+ expect(cmdAndArgs).toContain("bash");
396
+ expect(cmdAndArgs).toContain("-lc");
397
+ });
398
+
399
+ it("sets session pid from spawned process", () => {
400
+ mockSpawn.mockReturnValue(createMockProc(99999));
401
+ const info = launcher.launch({ cwd: "/tmp" });
402
+ expect(info.pid).toBe(99999);
403
+ });
404
+
405
+ it("unsets CLAUDECODE to avoid CLI nesting guard", () => {
406
+ launcher.launch({ cwd: "/tmp" });
407
+
408
+ const [, options] = mockSpawn.mock.calls[0];
409
+ expect(options.env.CLAUDECODE).toBeUndefined();
410
+ });
411
+
412
+ it("merges custom env variables", () => {
413
+ launcher.launch({
414
+ cwd: "/tmp",
415
+ env: { MY_VAR: "hello" },
416
+ });
417
+
418
+ const [, options] = mockSpawn.mock.calls[0];
419
+ expect(options.env.MY_VAR).toBe("hello");
420
+ expect(options.env.CLAUDECODE).toBeUndefined();
421
+ });
422
+
423
+ it("enables Codex web search when codexInternetAccess=true", () => {
424
+ // Use a fake path where no sibling `node` exists, so the spawn uses
425
+ // the codex binary directly (the explicit-node path is tested separately).
426
+ mockResolveBinary.mockReturnValue("/opt/fake/codex");
427
+ mockSpawn.mockReturnValueOnce(createMockCodexProc());
428
+
429
+ launcher.launch({
430
+ backendType: "codex",
431
+ cwd: "/tmp/project",
432
+ codexInternetAccess: true,
433
+ codexSandbox: "danger-full-access",
434
+ });
435
+
436
+ const [cmdAndArgs, options] = mockSpawn.mock.calls[0];
437
+ expect(cmdAndArgs[0]).toBe("/opt/fake/codex");
438
+ expect(cmdAndArgs).toContain("app-server");
439
+ expect(cmdAndArgs).toContain("--enable");
440
+ expect(cmdAndArgs).toContain("multi_agent");
441
+ expect(cmdAndArgs).toContain("-c");
442
+ expect(cmdAndArgs).toContain("tools.webSearch=true");
443
+ expect(options.cwd).toBe("/tmp/project");
444
+ });
445
+
446
+ it("disables Codex web search when codexInternetAccess=false", () => {
447
+ mockResolveBinary.mockReturnValue("/opt/fake/codex");
448
+ mockSpawn.mockReturnValueOnce(createMockCodexProc());
449
+
450
+ launcher.launch({
451
+ backendType: "codex",
452
+ cwd: "/tmp/project",
453
+ codexInternetAccess: false,
454
+ codexSandbox: "workspace-write",
455
+ });
456
+
457
+ const [cmdAndArgs] = mockSpawn.mock.calls[0];
458
+ expect(cmdAndArgs).toContain("app-server");
459
+ expect(cmdAndArgs).toContain("--enable");
460
+ expect(cmdAndArgs).toContain("multi_agent");
461
+ expect(cmdAndArgs).toContain("-c");
462
+ expect(cmdAndArgs).toContain("tools.webSearch=false");
463
+ });
464
+
465
+ it("spawns codex via sibling node binary to bypass shebang issues", () => {
466
+ // When a `node` binary exists next to the resolved `codex`, the launcher
467
+ // should invoke `node <codex-script>` directly instead of relying on
468
+ // the #!/usr/bin/env node shebang (which may resolve to system Node v12).
469
+ // Create a temp dir with both `codex` and `node` files to simulate nvm layout.
470
+ const tmpBinDir = mkdtempSync(join(tmpdir(), "codex-test-"));
471
+ const fakeCodex = join(tmpBinDir, "codex");
472
+ const fakeNode = join(tmpBinDir, "node");
473
+ const { writeFileSync: realWriteFileSync } = require("node:fs");
474
+ realWriteFileSync(fakeCodex, "#!/usr/bin/env node\n");
475
+ realWriteFileSync(fakeNode, "#!/bin/sh\n");
476
+
477
+ mockResolveBinary.mockReturnValue(fakeCodex);
478
+ mockSpawn.mockReturnValueOnce(createMockCodexProc());
479
+
480
+ launcher.launch({
481
+ backendType: "codex",
482
+ cwd: "/tmp/project",
483
+ codexSandbox: "workspace-write",
484
+ });
485
+
486
+ const [cmdAndArgs] = mockSpawn.mock.calls[0];
487
+ // Sibling node exists, so it should use explicit node invocation
488
+ expect(cmdAndArgs[0]).toBe(fakeNode);
489
+ // The codex script path should be arg 1
490
+ expect(cmdAndArgs[1]).toContain("codex");
491
+ expect(cmdAndArgs).toContain("app-server");
492
+ expect(cmdAndArgs).toContain("--enable");
493
+ expect(cmdAndArgs).toContain("multi_agent");
494
+
495
+ // Cleanup
496
+ rmSync(tmpBinDir, { recursive: true, force: true });
497
+ });
498
+
499
+ it("sets state=exited and exitCode=127 when codex binary not found", () => {
500
+ mockResolveBinary.mockReturnValue(null);
501
+
502
+ const info = launcher.launch({
503
+ backendType: "codex",
504
+ cwd: "/tmp/project",
505
+ codexSandbox: "workspace-write",
506
+ });
507
+
508
+ expect(info.state).toBe("exited");
509
+ expect(info.exitCode).toBe(127);
510
+ expect(mockSpawn).not.toHaveBeenCalled();
511
+ });
512
+
513
+ });
514
+
515
+ // ─── state management ────────────────────────────────────────────────────────
516
+
517
+ describe("state management", () => {
518
+ describe("markConnected", () => {
519
+ it("sets state to connected", () => {
520
+ launcher.launch({ cwd: "/tmp" });
521
+ launcher.markConnected("test-session-id");
522
+
523
+ const session = launcher.getSession("test-session-id");
524
+ expect(session?.state).toBe("connected");
525
+ });
526
+
527
+ it("does nothing for unknown session", () => {
528
+ // Should not throw
529
+ launcher.markConnected("nonexistent");
530
+ });
531
+ });
532
+
533
+ describe("setCLISessionId", () => {
534
+ it("stores the CLI session ID", () => {
535
+ launcher.launch({ cwd: "/tmp" });
536
+ launcher.setCLISessionId("test-session-id", "cli-internal-abc");
537
+
538
+ const session = launcher.getSession("test-session-id");
539
+ expect(session?.cliSessionId).toBe("cli-internal-abc");
540
+ });
541
+
542
+ it("does nothing for unknown session", () => {
543
+ // Should not throw
544
+ launcher.setCLISessionId("nonexistent", "cli-id");
545
+ });
546
+ });
547
+
548
+ describe("isAlive", () => {
549
+ it("returns true for non-exited session", () => {
550
+ launcher.launch({ cwd: "/tmp" });
551
+ expect(launcher.isAlive("test-session-id")).toBe(true);
552
+ });
553
+
554
+ it("returns false for exited session", async () => {
555
+ launcher.launch({ cwd: "/tmp" });
556
+
557
+ // Simulate process exit
558
+ exitResolve(0);
559
+ // Allow the .then callback in spawnCLI to run
560
+ await new Promise((r) => setTimeout(r, 10));
561
+
562
+ expect(launcher.isAlive("test-session-id")).toBe(false);
563
+ });
564
+
565
+ it("returns false for unknown session", () => {
566
+ expect(launcher.isAlive("nonexistent")).toBe(false);
567
+ });
568
+ });
569
+
570
+ describe("listSessions", () => {
571
+ it("returns all sessions", () => {
572
+ // Because randomUUID is mocked to always return the same value,
573
+ // we need to test with a single launch. But we can verify the list.
574
+ launcher.launch({ cwd: "/tmp" });
575
+ const sessions = launcher.listSessions();
576
+
577
+ expect(sessions).toHaveLength(1);
578
+ expect(sessions[0].sessionId).toBe("test-session-id");
579
+ });
580
+
581
+ it("returns empty array when no sessions exist", () => {
582
+ expect(launcher.listSessions()).toEqual([]);
583
+ });
584
+ });
585
+
586
+ describe("getSession", () => {
587
+ it("returns a specific session", () => {
588
+ launcher.launch({ cwd: "/tmp/myproject" });
589
+
590
+ const session = launcher.getSession("test-session-id");
591
+ expect(session).toBeDefined();
592
+ expect(session?.cwd).toBe("/tmp/myproject");
593
+ });
594
+
595
+ it("returns undefined for unknown session", () => {
596
+ expect(launcher.getSession("nonexistent")).toBeUndefined();
597
+ });
598
+ });
599
+
600
+ describe("pruneExited", () => {
601
+ it("removes exited sessions and returns count", async () => {
602
+ launcher.launch({ cwd: "/tmp" });
603
+
604
+ // Simulate process exit
605
+ exitResolve(0);
606
+ await new Promise((r) => setTimeout(r, 10));
607
+
608
+ expect(launcher.getSession("test-session-id")?.state).toBe("exited");
609
+
610
+ const pruned = launcher.pruneExited();
611
+ expect(pruned).toBe(1);
612
+ expect(launcher.listSessions()).toHaveLength(0);
613
+ });
614
+
615
+ it("returns 0 when no sessions are exited", () => {
616
+ launcher.launch({ cwd: "/tmp" });
617
+ const pruned = launcher.pruneExited();
618
+ expect(pruned).toBe(0);
619
+ expect(launcher.listSessions()).toHaveLength(1);
620
+ });
621
+ });
622
+
623
+ describe("setArchived", () => {
624
+ it("sets the archived flag on a session", () => {
625
+ launcher.launch({ cwd: "/tmp" });
626
+ launcher.setArchived("test-session-id", true);
627
+
628
+ const session = launcher.getSession("test-session-id");
629
+ expect(session?.archived).toBe(true);
630
+ });
631
+
632
+ it("can unset the archived flag", () => {
633
+ launcher.launch({ cwd: "/tmp" });
634
+ launcher.setArchived("test-session-id", true);
635
+ launcher.setArchived("test-session-id", false);
636
+
637
+ const session = launcher.getSession("test-session-id");
638
+ expect(session?.archived).toBe(false);
639
+ });
640
+
641
+ it("does nothing for unknown session", () => {
642
+ // Should not throw
643
+ launcher.setArchived("nonexistent", true);
644
+ });
645
+ });
646
+
647
+ describe("removeSession", () => {
648
+ it("deletes session from internal maps", () => {
649
+ launcher.launch({ cwd: "/tmp" });
650
+ expect(launcher.getSession("test-session-id")).toBeDefined();
651
+
652
+ launcher.removeSession("test-session-id");
653
+ expect(launcher.getSession("test-session-id")).toBeUndefined();
654
+ expect(launcher.listSessions()).toHaveLength(0);
655
+ });
656
+
657
+ it("does nothing for unknown session", () => {
658
+ // Should not throw
659
+ launcher.removeSession("nonexistent");
660
+ });
661
+ });
662
+ });
663
+
664
+ // ─── kill ────────────────────────────────────────────────────────────────────
665
+
666
+ describe("kill", () => {
667
+ it("sends SIGTERM via proc.kill", async () => {
668
+ launcher.launch({ cwd: "/tmp" });
669
+
670
+ // Grab the mock proc
671
+ const mockProc = mockSpawn.mock.results[0].value;
672
+
673
+ // Resolve the exit promise so kill() doesn't wait on the timeout
674
+ setTimeout(() => exitResolve(0), 5);
675
+
676
+ const result = await launcher.kill("test-session-id");
677
+
678
+ expect(result).toBe(true);
679
+ expect(mockProc.kill).toHaveBeenCalledWith("SIGTERM");
680
+ });
681
+
682
+ it("marks session as exited", async () => {
683
+ launcher.launch({ cwd: "/tmp" });
684
+
685
+ setTimeout(() => exitResolve(0), 5);
686
+ await launcher.kill("test-session-id");
687
+
688
+ const session = launcher.getSession("test-session-id");
689
+ expect(session?.state).toBe("exited");
690
+ expect(session?.exitCode).toBe(-1);
691
+ });
692
+
693
+ it("returns false for unknown session", async () => {
694
+ const result = await launcher.kill("nonexistent");
695
+ expect(result).toBe(false);
696
+ });
697
+ });
698
+
699
+ // ─── relaunch ────────────────────────────────────────────────────────────────
700
+
701
+ describe("relaunch", () => {
702
+ it("kills old process and spawns new one with --resume", async () => {
703
+ // Create first proc whose exit resolves immediately when killed
704
+ let resolveFirst: (code: number) => void;
705
+ const firstProc = {
706
+ pid: 12345,
707
+ kill: vi.fn(() => { resolveFirst(0); }),
708
+ exited: new Promise<number>((r) => { resolveFirst = r; }),
709
+ stdout: null,
710
+ stderr: null,
711
+ };
712
+ mockSpawn.mockReturnValueOnce(firstProc);
713
+
714
+ launcher.launch({ cwd: "/tmp/project", model: "claude-sonnet-4-6" });
715
+ launcher.setCLISessionId("test-session-id", "cli-resume-id");
716
+
717
+ // Second proc for the relaunch — never exits during test
718
+ const secondProc = createMockProc(54321);
719
+ mockSpawn.mockReturnValueOnce(secondProc);
720
+
721
+ const result = await launcher.relaunch("test-session-id");
722
+ expect(result).toEqual({ ok: true });
723
+
724
+ // Old process should have been killed
725
+ expect(firstProc.kill).toHaveBeenCalledWith("SIGTERM");
726
+
727
+ // New process should be spawned with --resume
728
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
729
+ const [cmdAndArgs] = mockSpawn.mock.calls[1];
730
+ expect(cmdAndArgs).toContain("--resume");
731
+ expect(cmdAndArgs).toContain("cli-resume-id");
732
+
733
+ // Session state should be reset to starting (set by relaunch before spawnCLI)
734
+ // Allow microtask queue to flush
735
+ await new Promise((r) => setTimeout(r, 10));
736
+ const session = launcher.getSession("test-session-id");
737
+ expect(session?.state).toBe("starting");
738
+ });
739
+
740
+ it("reuses launch env variables during relaunch", async () => {
741
+ let resolveFirst: (code: number) => void;
742
+ const firstProc = {
743
+ pid: 12345,
744
+ kill: vi.fn(() => { resolveFirst(0); }),
745
+ exited: new Promise<number>((r) => { resolveFirst = r; }),
746
+ stdout: null,
747
+ stderr: null,
748
+ };
749
+ mockSpawn.mockReturnValueOnce(firstProc);
750
+
751
+ launcher.launch({
752
+ cwd: "/tmp/project",
753
+ containerId: "abc123def456",
754
+ containerName: "companion-test",
755
+ env: { CLAUDE_CODE_OAUTH_TOKEN: "tok-test" },
756
+ });
757
+
758
+ const secondProc = createMockProc(54321);
759
+ mockSpawn.mockReturnValueOnce(secondProc);
760
+
761
+ const result = await launcher.relaunch("test-session-id");
762
+ expect(result).toEqual({ ok: true });
763
+
764
+ const [relaunchCmd] = mockSpawn.mock.calls[1];
765
+ expect(relaunchCmd).toContain("-e");
766
+ expect(relaunchCmd).toContain("CLAUDE_CODE_OAUTH_TOKEN=tok-test");
767
+ });
768
+
769
+ it("returns error for unknown session", async () => {
770
+ const result = await launcher.relaunch("nonexistent");
771
+ expect(result.ok).toBe(false);
772
+ expect(result.error).toContain("Session not found");
773
+ });
774
+
775
+ it("returns error when container was removed externally", async () => {
776
+ // Launch a containerized session
777
+ launcher.launch({
778
+ cwd: "/tmp/project",
779
+ containerId: "abc123def456",
780
+ containerName: "companion-gone",
781
+ });
782
+
783
+ // Simulate container being removed
784
+ mockIsContainerAlive.mockReturnValueOnce("missing");
785
+
786
+ const result = await launcher.relaunch("test-session-id");
787
+ expect(result.ok).toBe(false);
788
+ expect(result.error).toContain("companion-gone");
789
+ expect(result.error).toContain("removed externally");
790
+
791
+ // Session should be marked as exited
792
+ const session = launcher.getSession("test-session-id");
793
+ expect(session?.state).toBe("exited");
794
+ expect(session?.exitCode).toBe(1);
795
+
796
+ // Should NOT have spawned a new process
797
+ expect(mockSpawn).toHaveBeenCalledTimes(1); // only the initial launch
798
+ });
799
+
800
+ it("restarts stopped container before spawning CLI", async () => {
801
+ // Create initial proc that exits immediately when killed
802
+ let resolveFirst: (code: number) => void;
803
+ const firstProc = {
804
+ pid: 12345,
805
+ kill: vi.fn(() => { resolveFirst(0); }),
806
+ exited: new Promise<number>((r) => { resolveFirst = r; }),
807
+ stdout: null,
808
+ stderr: null,
809
+ };
810
+ mockSpawn.mockReturnValueOnce(firstProc);
811
+
812
+ launcher.launch({
813
+ cwd: "/tmp/project",
814
+ containerId: "abc123def456",
815
+ containerName: "companion-stopped",
816
+ });
817
+
818
+ // Container is stopped but can be restarted
819
+ mockIsContainerAlive.mockReturnValueOnce("stopped");
820
+ mockHasBinaryInContainer.mockReturnValueOnce(true);
821
+
822
+ const secondProc = createMockProc(54321);
823
+ mockSpawn.mockReturnValueOnce(secondProc);
824
+
825
+ const result = await launcher.relaunch("test-session-id");
826
+ expect(result).toEqual({ ok: true });
827
+ expect(mockStartContainer).toHaveBeenCalledWith("abc123def456");
828
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
829
+ });
830
+
831
+ it("returns error when stopped container cannot be restarted", async () => {
832
+ launcher.launch({
833
+ cwd: "/tmp/project",
834
+ containerId: "abc123def456",
835
+ containerName: "companion-dead",
836
+ });
837
+
838
+ mockIsContainerAlive.mockReturnValueOnce("stopped");
839
+ mockStartContainer.mockImplementationOnce(() => { throw new Error("container start failed"); });
840
+
841
+ const result = await launcher.relaunch("test-session-id");
842
+ expect(result.ok).toBe(false);
843
+ expect(result.error).toContain("companion-dead");
844
+ expect(result.error).toContain("stopped");
845
+ expect(result.error).toContain("container start failed");
846
+ });
847
+
848
+ it("returns error when CLI binary not found in container", async () => {
849
+ launcher.launch({
850
+ cwd: "/tmp/project",
851
+ containerId: "abc123def456",
852
+ containerName: "companion-nobin",
853
+ });
854
+
855
+ mockIsContainerAlive.mockReturnValueOnce("running");
856
+ mockHasBinaryInContainer.mockReturnValueOnce(false);
857
+
858
+ const result = await launcher.relaunch("test-session-id");
859
+ expect(result.ok).toBe(false);
860
+ expect(result.error).toContain("claude");
861
+ expect(result.error).toContain("not found");
862
+ expect(result.error).toContain("companion-nobin");
863
+
864
+ const session = launcher.getSession("test-session-id");
865
+ expect(session?.state).toBe("exited");
866
+ expect(session?.exitCode).toBe(127);
867
+ });
868
+
869
+ it("skips container validation for non-containerized sessions", async () => {
870
+ // Create initial proc that exits when killed
871
+ let resolveFirst: (code: number) => void;
872
+ const firstProc = {
873
+ pid: 12345,
874
+ kill: vi.fn(() => { resolveFirst(0); }),
875
+ exited: new Promise<number>((r) => { resolveFirst = r; }),
876
+ stdout: null,
877
+ stderr: null,
878
+ };
879
+ mockSpawn.mockReturnValueOnce(firstProc);
880
+
881
+ launcher.launch({ cwd: "/tmp/project" });
882
+
883
+ const secondProc = createMockProc(54321);
884
+ mockSpawn.mockReturnValueOnce(secondProc);
885
+
886
+ const result = await launcher.relaunch("test-session-id");
887
+ expect(result).toEqual({ ok: true });
888
+
889
+ // Container validation methods should NOT have been called
890
+ expect(mockIsContainerAlive).not.toHaveBeenCalled();
891
+ expect(mockHasBinaryInContainer).not.toHaveBeenCalled();
892
+ });
893
+ });
894
+
895
+ // ─── codex websocket launcher ────────────────────────────────────────────────
896
+
897
+ describe("codex websocket launcher", () => {
898
+ it("spawns codex app-server and a node ws proxy, then attaches a CodexAdapter", async () => {
899
+ // Verify the WS transport path launches two subprocesses:
900
+ // 1) codex app-server --listen ...
901
+ // 2) a Node sidecar proxy that bridges stdio <-> WebSocket
902
+ process.env.COMPANION_CODEX_TRANSPORT = "ws";
903
+ mockResolveBinary.mockReturnValue("/opt/fake/codex");
904
+
905
+ const codexProc = createMockProc(2001);
906
+ const { proc: proxyProc } = createPendingCodexWsProxyProc(2002);
907
+ mockSpawn.mockReturnValueOnce(codexProc).mockReturnValueOnce(proxyProc);
908
+
909
+ const onAdapter = vi.fn();
910
+ companionBus.on("backend:codex-adapter-created", ({ sessionId, adapter }) => onAdapter(sessionId, adapter));
911
+
912
+ launcher.launch({
913
+ backendType: "codex",
914
+ cwd: "/tmp/project",
915
+ codexSandbox: "workspace-write",
916
+ });
917
+
918
+ await new Promise((r) => setTimeout(r, 0));
919
+
920
+ expect(mockListen).toHaveBeenCalled();
921
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
922
+
923
+ const [codexCmd] = mockSpawn.mock.calls[0];
924
+ expect(codexCmd[0]).toBe("/opt/fake/codex");
925
+ expect(codexCmd).toContain("app-server");
926
+ expect(codexCmd).toContain("--enable");
927
+ expect(codexCmd).toContain("multi_agent");
928
+ expect(codexCmd).toContain("--listen");
929
+ expect(codexCmd).toContain("ws://127.0.0.1:4500");
930
+
931
+ const [proxyCmd, proxyOpts] = mockSpawn.mock.calls[1];
932
+ expect(proxyCmd[0]).toBe("node");
933
+ expect(proxyCmd[1]).toContain("codex-ws-proxy.cjs");
934
+ expect(proxyCmd[2]).toBe("ws://127.0.0.1:4500");
935
+ // Default connect timeout (30s) and pong timeout (30s) passed to proxy
936
+ expect(proxyCmd[3]).toBe("30000");
937
+ expect(proxyCmd[4]).toBe("30000");
938
+ expect(proxyOpts.stdin).toBe("pipe");
939
+ expect(proxyOpts.stdout).toBe("pipe");
940
+ expect(proxyOpts.stderr).toBe("pipe");
941
+
942
+ expect(onAdapter).toHaveBeenCalledTimes(1);
943
+ expect(onAdapter.mock.calls[0][0]).toBe("test-session-id");
944
+ });
945
+
946
+ it("skips already-claimed ws ports when selecting Codex host listen port", async () => {
947
+ process.env.COMPANION_CODEX_TRANSPORT = "ws";
948
+ mockResolveBinary.mockReturnValue("/opt/fake/codex");
949
+ (launcher as any).claimedCodexWsPorts.add(4500);
950
+
951
+ const codexProc = createMockProc(2101);
952
+ const { proc: proxyProc } = createPendingCodexWsProxyProc(2102);
953
+ mockSpawn.mockReturnValueOnce(codexProc).mockReturnValueOnce(proxyProc);
954
+
955
+ launcher.launch({
956
+ backendType: "codex",
957
+ cwd: "/tmp/project",
958
+ codexSandbox: "workspace-write",
959
+ });
960
+
961
+ await new Promise((r) => setTimeout(r, 0));
962
+
963
+ const [codexCmd] = mockSpawn.mock.calls[0];
964
+ expect(codexCmd).toContain("ws://127.0.0.1:4501");
965
+ });
966
+
967
+ it("passes custom connect and pong timeouts from env vars to the ws proxy", async () => {
968
+ // When COMPANION_CODEX_WS_CONNECT_TIMEOUT_MS and COMPANION_CODEX_PONG_TIMEOUT_MS
969
+ // are set, those values should be forwarded as argv[3] and argv[4] to the proxy.
970
+ process.env.COMPANION_CODEX_TRANSPORT = "ws";
971
+ process.env.COMPANION_CODEX_WS_CONNECT_TIMEOUT_MS = "60000";
972
+ process.env.COMPANION_CODEX_PONG_TIMEOUT_MS = "45000";
973
+ mockResolveBinary.mockReturnValue("/opt/fake/codex");
974
+
975
+ const codexProc = createMockProc(5001);
976
+ const { proc: proxyProc } = createPendingCodexWsProxyProc(5002);
977
+ mockSpawn.mockReturnValueOnce(codexProc).mockReturnValueOnce(proxyProc);
978
+
979
+ companionBus.on("backend:codex-adapter-created", vi.fn());
980
+ launcher.launch({
981
+ backendType: "codex",
982
+ cwd: "/tmp/project",
983
+ codexSandbox: "workspace-write",
984
+ });
985
+
986
+ await new Promise((r) => setTimeout(r, 0));
987
+
988
+ const [proxyCmd] = mockSpawn.mock.calls[1];
989
+ expect(proxyCmd[3]).toBe("60000");
990
+ expect(proxyCmd[4]).toBe("45000");
991
+ });
992
+
993
+ it("relaunch kills the old codex process and ws proxy before spawning replacements", async () => {
994
+ // Verify the WS sidecar is treated as part of session lifecycle during relaunch.
995
+ process.env.COMPANION_CODEX_TRANSPORT = "ws";
996
+ mockResolveBinary.mockReturnValue("/opt/fake/codex");
997
+
998
+ let resolveCodex1!: (code: number) => void;
999
+ const codexProc1 = {
1000
+ pid: 3001,
1001
+ kill: vi.fn(() => resolveCodex1(0)),
1002
+ exited: new Promise<number>((r) => { resolveCodex1 = r; }),
1003
+ stdout: null,
1004
+ stderr: null,
1005
+ };
1006
+ const proxy1 = createPendingCodexWsProxyProc(3002);
1007
+ proxy1.proc.kill.mockImplementation(() => proxy1.resolveExit(0));
1008
+
1009
+ const codexProc2 = createMockProc(3003);
1010
+ const proxy2 = createPendingCodexWsProxyProc(3004);
1011
+
1012
+ mockSpawn
1013
+ .mockReturnValueOnce(codexProc1 as any)
1014
+ .mockReturnValueOnce(proxy1.proc as any)
1015
+ .mockReturnValueOnce(codexProc2 as any)
1016
+ .mockReturnValueOnce(proxy2.proc as any);
1017
+
1018
+ launcher.launch({
1019
+ backendType: "codex",
1020
+ cwd: "/tmp/project",
1021
+ codexSandbox: "workspace-write",
1022
+ });
1023
+
1024
+ await new Promise((r) => setTimeout(r, 0));
1025
+
1026
+ const result = await launcher.relaunch("test-session-id");
1027
+ expect(result).toEqual({ ok: true });
1028
+ expect(codexProc1.kill).toHaveBeenCalledWith("SIGTERM");
1029
+ expect(proxy1.proc.kill).toHaveBeenCalledWith("SIGTERM");
1030
+ expect(mockSpawn).toHaveBeenCalledTimes(4);
1031
+ });
1032
+
1033
+ it("kill() returns true and kills the proxy when only a ws proxy remains", async () => {
1034
+ // Exercise the proxy-only branch introduced for WS cleanup robustness.
1035
+ launcher.launch({ cwd: "/tmp/project" });
1036
+ const proxyOnly = createPendingCodexWsProxyProc(4001);
1037
+
1038
+ (launcher as any).processes.delete("test-session-id");
1039
+ (launcher as any).codexWsProxies.set("test-session-id", proxyOnly.proc);
1040
+
1041
+ const result = await launcher.kill("test-session-id");
1042
+ expect(result).toBe(true);
1043
+ expect(proxyOnly.proc.kill).toHaveBeenCalledWith("SIGTERM");
1044
+ });
1045
+
1046
+ it("containerized codex ws mode ignores detached launcher exit and uses proxy exit for session liveness", async () => {
1047
+ // In container WS mode, docker exec -d exits immediately after launching Codex.
1048
+ // The session must remain alive until the proxy (actual transport) exits.
1049
+ process.env.COMPANION_CODEX_TRANSPORT = "ws";
1050
+ mockGetContainerById.mockReturnValue({
1051
+ containerId: "abc123def456",
1052
+ name: "companion-codex",
1053
+ image: "the-companion:latest",
1054
+ portMappings: [{ containerPort: 4502, hostPort: 55021 }],
1055
+ hostCwd: "/tmp/project",
1056
+ containerCwd: "/workspace",
1057
+ state: "running",
1058
+ });
1059
+
1060
+ let resolveLauncherProc!: (code: number) => void;
1061
+ const detachedLauncherProc = {
1062
+ pid: 5001,
1063
+ kill: vi.fn(),
1064
+ exited: new Promise<number>((r) => { resolveLauncherProc = r; }),
1065
+ stdout: null,
1066
+ stderr: null,
1067
+ };
1068
+ const proxy = createPendingCodexWsProxyProc(5002);
1069
+
1070
+ mockSpawn
1071
+ .mockReturnValueOnce(detachedLauncherProc as any)
1072
+ .mockReturnValueOnce(proxy.proc as any);
1073
+
1074
+ launcher.launch({
1075
+ backendType: "codex",
1076
+ cwd: "/tmp/project",
1077
+ codexSandbox: "workspace-write",
1078
+ containerId: "abc123def456",
1079
+ containerName: "companion-codex",
1080
+ });
1081
+
1082
+ await new Promise((r) => setTimeout(r, 0));
1083
+
1084
+ const [codexCmd] = mockSpawn.mock.calls[0];
1085
+ const codexBashCmd = codexCmd[codexCmd.length - 1];
1086
+ expect(codexBashCmd).toContain("--enable");
1087
+ expect(codexBashCmd).toContain("multi_agent");
1088
+ expect(codexBashCmd).toContain("--listen");
1089
+ expect(codexBashCmd).toContain("ws://0.0.0.0:4502");
1090
+
1091
+ const [proxyCmd] = mockSpawn.mock.calls[1];
1092
+ expect(proxyCmd[2]).toBe("ws://127.0.0.1:55021");
1093
+
1094
+ resolveLauncherProc(0);
1095
+ await new Promise((r) => setTimeout(r, 0));
1096
+
1097
+ expect(launcher.getSession("test-session-id")?.state).not.toBe("exited");
1098
+
1099
+ proxy.resolveExit(7);
1100
+ await new Promise((r) => setTimeout(r, 0));
1101
+
1102
+ const session = launcher.getSession("test-session-id");
1103
+ expect(session?.state).toBe("exited");
1104
+ expect(session?.exitCode).toBe(7);
1105
+ });
1106
+ });
1107
+
1108
+ // ─── persistence ─────────────────────────────────────────────────────────────
1109
+
1110
+ describe("persistence", () => {
1111
+ describe("restoreFromDisk", () => {
1112
+ it("recovers sessions from the store", () => {
1113
+ // Manually write launcher data to disk to simulate a previous run
1114
+ const savedSessions = [
1115
+ {
1116
+ sessionId: "restored-1",
1117
+ pid: 99999,
1118
+ state: "connected" as const,
1119
+ cwd: "/tmp/project",
1120
+ createdAt: Date.now(),
1121
+ cliSessionId: "cli-abc",
1122
+ },
1123
+ ];
1124
+ store.saveLauncher(savedSessions);
1125
+
1126
+ // Mock process.kill(pid, 0) to succeed (process is alive)
1127
+ const origKill = process.kill;
1128
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(((
1129
+ pid: number,
1130
+ signal?: string | number,
1131
+ ) => {
1132
+ if (signal === 0) return true;
1133
+ return origKill.call(process, pid, signal as any);
1134
+ }) as any);
1135
+
1136
+ const newLauncher = new CliLauncher(3456);
1137
+ newLauncher.setStore(store);
1138
+ const recovered = newLauncher.restoreFromDisk();
1139
+
1140
+ expect(recovered).toBe(1);
1141
+
1142
+ const session = newLauncher.getSession("restored-1");
1143
+ expect(session).toBeDefined();
1144
+ // Live PIDs get state reset to "starting" awaiting WS reconnect
1145
+ expect(session?.state).toBe("starting");
1146
+ expect(session?.cliSessionId).toBe("cli-abc");
1147
+
1148
+ killSpy.mockRestore();
1149
+ });
1150
+
1151
+ it("marks dead PIDs as exited", () => {
1152
+ const savedSessions = [
1153
+ {
1154
+ sessionId: "dead-1",
1155
+ pid: 11111,
1156
+ state: "connected" as const,
1157
+ cwd: "/tmp/project",
1158
+ createdAt: Date.now(),
1159
+ },
1160
+ ];
1161
+ store.saveLauncher(savedSessions);
1162
+
1163
+ // Mock process.kill(pid, 0) to throw (process is dead)
1164
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(((
1165
+ _pid: number,
1166
+ signal?: string | number,
1167
+ ) => {
1168
+ if (signal === 0) throw new Error("ESRCH");
1169
+ return true;
1170
+ }) as any);
1171
+
1172
+ const newLauncher = new CliLauncher(3456);
1173
+ newLauncher.setStore(store);
1174
+ const recovered = newLauncher.restoreFromDisk();
1175
+
1176
+ // Dead sessions don't count as recovered
1177
+ expect(recovered).toBe(0);
1178
+
1179
+ const session = newLauncher.getSession("dead-1");
1180
+ expect(session).toBeDefined();
1181
+ expect(session?.state).toBe("exited");
1182
+ expect(session?.exitCode).toBe(-1);
1183
+
1184
+ killSpy.mockRestore();
1185
+ });
1186
+
1187
+ it("returns 0 when no store is set", () => {
1188
+ const newLauncher = new CliLauncher(3456);
1189
+ // No setStore call
1190
+ expect(newLauncher.restoreFromDisk()).toBe(0);
1191
+ });
1192
+
1193
+ it("returns 0 when store has no launcher data", () => {
1194
+ const newLauncher = new CliLauncher(3456);
1195
+ newLauncher.setStore(store);
1196
+ // Store is empty, no launcher.json file
1197
+ expect(newLauncher.restoreFromDisk()).toBe(0);
1198
+ });
1199
+
1200
+ it("recovers Docker WS sessions using container liveness instead of PID", () => {
1201
+ // Docker WS mode sessions have containerId + codexWsPort.
1202
+ // The stored PID is from `docker exec -d` which exits immediately,
1203
+ // so container liveness must be checked instead.
1204
+ const savedSessions = [
1205
+ {
1206
+ sessionId: "docker-ws-1",
1207
+ pid: 55555,
1208
+ state: "connected" as const,
1209
+ cwd: "/tmp/project",
1210
+ createdAt: Date.now(),
1211
+ containerId: "abc123",
1212
+ codexWsPort: 32819,
1213
+ },
1214
+ ];
1215
+ store.saveLauncher(savedSessions);
1216
+
1217
+ mockIsContainerAlive.mockReturnValueOnce("running");
1218
+
1219
+ const newLauncher = new CliLauncher(3456);
1220
+ newLauncher.setStore(store);
1221
+ const recovered = newLauncher.restoreFromDisk();
1222
+
1223
+ expect(recovered).toBe(1);
1224
+ expect(mockIsContainerAlive).toHaveBeenCalledWith("abc123");
1225
+
1226
+ const session = newLauncher.getSession("docker-ws-1");
1227
+ expect(session).toBeDefined();
1228
+ expect(session?.state).toBe("starting");
1229
+ });
1230
+
1231
+ it("marks Docker WS sessions as exited when container is stopped", () => {
1232
+ const savedSessions = [
1233
+ {
1234
+ sessionId: "docker-ws-dead",
1235
+ pid: 66666,
1236
+ state: "connected" as const,
1237
+ cwd: "/tmp/project",
1238
+ createdAt: Date.now(),
1239
+ containerId: "dead-container",
1240
+ codexWsPort: 32820,
1241
+ },
1242
+ ];
1243
+ store.saveLauncher(savedSessions);
1244
+
1245
+ mockIsContainerAlive.mockReturnValueOnce("stopped");
1246
+
1247
+ const newLauncher = new CliLauncher(3456);
1248
+ newLauncher.setStore(store);
1249
+ const recovered = newLauncher.restoreFromDisk();
1250
+
1251
+ expect(recovered).toBe(0);
1252
+ expect(mockIsContainerAlive).toHaveBeenCalledWith("dead-container");
1253
+
1254
+ const session = newLauncher.getSession("docker-ws-dead");
1255
+ expect(session).toBeDefined();
1256
+ expect(session?.state).toBe("exited");
1257
+ expect(session?.exitCode).toBe(-1);
1258
+ });
1259
+
1260
+ it("preserves already-exited sessions from disk", () => {
1261
+ const savedSessions = [
1262
+ {
1263
+ sessionId: "already-exited",
1264
+ pid: 22222,
1265
+ state: "exited" as const,
1266
+ exitCode: 0,
1267
+ cwd: "/tmp/project",
1268
+ createdAt: Date.now(),
1269
+ },
1270
+ ];
1271
+ store.saveLauncher(savedSessions);
1272
+
1273
+ const newLauncher = new CliLauncher(3456);
1274
+ newLauncher.setStore(store);
1275
+ const recovered = newLauncher.restoreFromDisk();
1276
+
1277
+ // Already-exited sessions are loaded but not "recovered"
1278
+ expect(recovered).toBe(0);
1279
+ const session = newLauncher.getSession("already-exited");
1280
+ expect(session).toBeDefined();
1281
+ expect(session?.state).toBe("exited");
1282
+ });
1283
+ });
1284
+ });
1285
+
1286
+ // ─── getStartingSessions ─────────────────────────────────────────────────────
1287
+
1288
+ describe("getStartingSessions", () => {
1289
+ it("returns only sessions in starting state", () => {
1290
+ launcher.launch({ cwd: "/tmp" });
1291
+
1292
+ const starting = launcher.getStartingSessions();
1293
+ expect(starting).toHaveLength(1);
1294
+ expect(starting[0].state).toBe("starting");
1295
+ });
1296
+
1297
+ it("excludes sessions that have been connected", () => {
1298
+ launcher.launch({ cwd: "/tmp" });
1299
+ launcher.markConnected("test-session-id");
1300
+
1301
+ const starting = launcher.getStartingSessions();
1302
+ expect(starting).toHaveLength(0);
1303
+ });
1304
+
1305
+ it("returns empty array when no sessions exist", () => {
1306
+ expect(launcher.getStartingSessions()).toEqual([]);
1307
+ });
1308
+ });
1309
+
1310
+ // ─── isCmdScript platform guard ───────────────────────────────────────────────
1311
+
1312
+ describe("isCmdScript platform guard", () => {
1313
+ const originalPlatform = process.platform;
1314
+
1315
+ afterEach(() => {
1316
+ Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
1317
+ });
1318
+
1319
+ it("wraps .cmd binary with cmd.exe /c on win32", () => {
1320
+ Object.defineProperty(process, "platform", { value: "win32", configurable: true });
1321
+ mockResolveBinary.mockReturnValue("C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd");
1322
+
1323
+ launcher.launch({ cwd: "/tmp" });
1324
+
1325
+ const [cmdAndArgs] = mockSpawn.mock.calls[0];
1326
+ // On Windows, .cmd files should be spawned via cmd.exe /c
1327
+ expect(cmdAndArgs[0]).toBe("cmd.exe");
1328
+ expect(cmdAndArgs[1]).toBe("/c");
1329
+ expect(cmdAndArgs[2]).toBe("C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd");
1330
+ });
1331
+
1332
+ it("does not wrap .cmd binary with cmd.exe on non-Windows", () => {
1333
+ Object.defineProperty(process, "platform", { value: "linux", configurable: true });
1334
+ mockResolveBinary.mockReturnValue("/usr/local/bin/claude.cmd");
1335
+
1336
+ launcher.launch({ cwd: "/tmp" });
1337
+
1338
+ const [cmdAndArgs] = mockSpawn.mock.calls[0];
1339
+ // On non-Windows, .cmd files should be spawned directly (no cmd.exe wrapping)
1340
+ expect(cmdAndArgs[0]).toBe("/usr/local/bin/claude.cmd");
1341
+ expect(cmdAndArgs[0]).not.toBe("cmd.exe");
1342
+ });
1343
+ });