heyhank 0.1.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 (199) hide show
  1. package/README.md +40 -0
  2. package/bin/cli.ts +168 -0
  3. package/bin/ctl.ts +528 -0
  4. package/bin/generate-token.ts +28 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
  7. package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
  8. package/dist/assets/CronManager-DDbz-yiT.js +1 -0
  9. package/dist/assets/HelpPage-DMfkzERp.js +1 -0
  10. package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
  11. package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
  12. package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
  13. package/dist/assets/Playground-Fc5cdc5p.js +109 -0
  14. package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
  15. package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
  16. package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
  17. package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
  18. package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
  19. package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
  20. package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
  21. package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
  22. package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
  23. package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
  24. package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
  25. package/dist/assets/index-C8M_PUmX.css +32 -0
  26. package/dist/assets/index-CEqZnThB.js +204 -0
  27. package/dist/assets/sw-register-LSSpj6RU.js +1 -0
  28. package/dist/assets/time-ago-B6r_l9u1.js +1 -0
  29. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  30. package/dist/favicon-32-original.png +0 -0
  31. package/dist/favicon-32.png +0 -0
  32. package/dist/favicon.ico +0 -0
  33. package/dist/favicon.svg +8 -0
  34. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  35. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  36. package/dist/heyhank-mascot-poster.png +0 -0
  37. package/dist/heyhank-mascot.mp4 +0 -0
  38. package/dist/heyhank-mascot.webm +0 -0
  39. package/dist/icon-192-original.png +0 -0
  40. package/dist/icon-192.png +0 -0
  41. package/dist/icon-512-original.png +0 -0
  42. package/dist/icon-512.png +0 -0
  43. package/dist/index.html +21 -0
  44. package/dist/logo-192.png +0 -0
  45. package/dist/logo-512.png +0 -0
  46. package/dist/logo-codex.svg +14 -0
  47. package/dist/logo-docker.svg +4 -0
  48. package/dist/logo-original.png +0 -0
  49. package/dist/logo.png +0 -0
  50. package/dist/logo.svg +14 -0
  51. package/dist/manifest.json +24 -0
  52. package/dist/push-sw.js +34 -0
  53. package/dist/sw.js +1 -0
  54. package/dist/workbox-d2a0910a.js +1 -0
  55. package/package.json +109 -0
  56. package/server/agent-cron-migrator.ts +85 -0
  57. package/server/agent-executor.ts +357 -0
  58. package/server/agent-store.ts +185 -0
  59. package/server/agent-timeout.ts +107 -0
  60. package/server/agent-types.ts +122 -0
  61. package/server/ai-validation-settings.ts +37 -0
  62. package/server/ai-validator.ts +181 -0
  63. package/server/anthropic-provider-migration.ts +48 -0
  64. package/server/assistant-store.ts +272 -0
  65. package/server/auth-manager.ts +150 -0
  66. package/server/auto-approve.ts +153 -0
  67. package/server/auto-namer.ts +36 -0
  68. package/server/backend-adapter.ts +54 -0
  69. package/server/cache-headers.ts +61 -0
  70. package/server/calendar-service.ts +434 -0
  71. package/server/claude-adapter.ts +889 -0
  72. package/server/claude-container-auth.ts +30 -0
  73. package/server/claude-session-discovery.ts +157 -0
  74. package/server/claude-session-history.ts +410 -0
  75. package/server/cli-launcher.ts +1303 -0
  76. package/server/codex-adapter.ts +3027 -0
  77. package/server/codex-container-auth.ts +24 -0
  78. package/server/codex-home.ts +27 -0
  79. package/server/codex-ws-proxy.cjs +226 -0
  80. package/server/commands-discovery.ts +81 -0
  81. package/server/constants.ts +7 -0
  82. package/server/container-manager.ts +1053 -0
  83. package/server/cost-tracker.ts +222 -0
  84. package/server/cron-scheduler.ts +243 -0
  85. package/server/cron-store.ts +148 -0
  86. package/server/cron-types.ts +63 -0
  87. package/server/email-service.ts +354 -0
  88. package/server/env-manager.ts +161 -0
  89. package/server/event-bus-types.ts +75 -0
  90. package/server/event-bus.ts +124 -0
  91. package/server/execution-store.ts +170 -0
  92. package/server/federation/node-connection.ts +190 -0
  93. package/server/federation/node-manager.ts +366 -0
  94. package/server/federation/node-store.ts +86 -0
  95. package/server/federation/node-types.ts +121 -0
  96. package/server/fs-utils.ts +15 -0
  97. package/server/git-utils.ts +421 -0
  98. package/server/github-pr.ts +379 -0
  99. package/server/google-media.ts +342 -0
  100. package/server/image-pull-manager.ts +279 -0
  101. package/server/index.ts +491 -0
  102. package/server/internal-ai.ts +237 -0
  103. package/server/kill-switch.ts +99 -0
  104. package/server/llm-providers.ts +342 -0
  105. package/server/logger.ts +259 -0
  106. package/server/mcp-registry.ts +401 -0
  107. package/server/message-bus.ts +271 -0
  108. package/server/message-delivery.ts +128 -0
  109. package/server/metrics-collector.ts +350 -0
  110. package/server/metrics-types.ts +108 -0
  111. package/server/middleware/managed-auth.ts +195 -0
  112. package/server/novnc-proxy.ts +99 -0
  113. package/server/path-resolver.ts +186 -0
  114. package/server/paths.ts +13 -0
  115. package/server/pr-poller.ts +162 -0
  116. package/server/prompt-manager.ts +211 -0
  117. package/server/protocol/claude-upstream/README.md +19 -0
  118. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  119. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  120. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  121. package/server/protocol/codex-upstream/README.md +18 -0
  122. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  123. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  124. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  125. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  126. package/server/protocol-monitor.ts +50 -0
  127. package/server/provider-manager.ts +111 -0
  128. package/server/provider-registry.ts +393 -0
  129. package/server/push-notifications.ts +221 -0
  130. package/server/recorder.ts +374 -0
  131. package/server/recording-hub/compat-validator.ts +284 -0
  132. package/server/recording-hub/diagnostics.ts +299 -0
  133. package/server/recording-hub/hub-config.ts +19 -0
  134. package/server/recording-hub/hub-routes.ts +236 -0
  135. package/server/recording-hub/hub-store.ts +265 -0
  136. package/server/recording-hub/replay-adapter.ts +207 -0
  137. package/server/relay-client.ts +320 -0
  138. package/server/reminder-scheduler.ts +38 -0
  139. package/server/replay.ts +78 -0
  140. package/server/routes/agent-routes.ts +264 -0
  141. package/server/routes/assistant-routes.ts +90 -0
  142. package/server/routes/cron-routes.ts +103 -0
  143. package/server/routes/env-routes.ts +95 -0
  144. package/server/routes/federation-routes.ts +76 -0
  145. package/server/routes/fs-routes.ts +622 -0
  146. package/server/routes/git-routes.ts +97 -0
  147. package/server/routes/llm-routes.ts +166 -0
  148. package/server/routes/media-routes.ts +135 -0
  149. package/server/routes/metrics-routes.ts +13 -0
  150. package/server/routes/platform-routes.ts +1379 -0
  151. package/server/routes/prompt-routes.ts +67 -0
  152. package/server/routes/provider-routes.ts +109 -0
  153. package/server/routes/sandbox-routes.ts +127 -0
  154. package/server/routes/settings-routes.ts +285 -0
  155. package/server/routes/skills-routes.ts +100 -0
  156. package/server/routes/socialmedia-routes.ts +208 -0
  157. package/server/routes/system-routes.ts +228 -0
  158. package/server/routes/tailscale-routes.ts +22 -0
  159. package/server/routes/telephony-routes.ts +259 -0
  160. package/server/routes.ts +1379 -0
  161. package/server/sandbox-manager.ts +168 -0
  162. package/server/service.ts +718 -0
  163. package/server/session-creation-service.ts +457 -0
  164. package/server/session-git-info.ts +104 -0
  165. package/server/session-names.ts +67 -0
  166. package/server/session-orchestrator.ts +824 -0
  167. package/server/session-state-machine.ts +207 -0
  168. package/server/session-store.ts +146 -0
  169. package/server/session-types.ts +511 -0
  170. package/server/settings-manager.ts +149 -0
  171. package/server/shared-context.ts +157 -0
  172. package/server/socialmedia/adapter.ts +15 -0
  173. package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
  174. package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
  175. package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
  176. package/server/socialmedia/manager.ts +227 -0
  177. package/server/socialmedia/store.ts +98 -0
  178. package/server/socialmedia/types.ts +89 -0
  179. package/server/tailscale-manager.ts +451 -0
  180. package/server/telephony/audio-bridge.ts +331 -0
  181. package/server/telephony/call-manager.ts +457 -0
  182. package/server/telephony/call-types.ts +108 -0
  183. package/server/telephony/telephony-store.ts +119 -0
  184. package/server/terminal-manager.ts +240 -0
  185. package/server/update-checker.ts +192 -0
  186. package/server/usage-limits.ts +225 -0
  187. package/server/web-push.d.ts +51 -0
  188. package/server/worktree-tracker.ts +84 -0
  189. package/server/ws-auth.ts +41 -0
  190. package/server/ws-bridge-browser-ingest.ts +72 -0
  191. package/server/ws-bridge-browser.ts +112 -0
  192. package/server/ws-bridge-cli-ingest.ts +81 -0
  193. package/server/ws-bridge-codex.ts +266 -0
  194. package/server/ws-bridge-controls.ts +20 -0
  195. package/server/ws-bridge-persist.ts +66 -0
  196. package/server/ws-bridge-publish.ts +79 -0
  197. package/server/ws-bridge-replay.ts +61 -0
  198. package/server/ws-bridge-types.ts +121 -0
  199. package/server/ws-bridge.ts +1240 -0
@@ -0,0 +1,1303 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ mkdirSync,
4
+ existsSync,
5
+ copyFileSync,
6
+ cpSync,
7
+ realpathSync,
8
+ } from "node:fs";
9
+ import { join, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import type { Subprocess } from "bun";
12
+ import type { SessionStore } from "./session-store.js";
13
+ import type { BackendType } from "./session-types.js";
14
+ import type { RecorderManager } from "./recorder.js";
15
+ import { CodexAdapter } from "./codex-adapter.js";
16
+ import { resolveBinary, getEnrichedPath } from "./path-resolver.js";
17
+ import { containerManager } from "./container-manager.js";
18
+ import { heyHankBus } from "./event-bus.js";
19
+ import {
20
+ getLegacyCodexHome,
21
+ resolveHeyHankCodexSessionHome,
22
+ } from "./codex-home.js";
23
+
24
+ /** Whether WebSocket transport is enabled for Codex sessions. */
25
+ function isCodexWsTransportEnabled(): boolean {
26
+ const val = (process.env.HEYHANK_CODEX_TRANSPORT || process.env.COMPANION_CODEX_TRANSPORT || "ws").toLowerCase();
27
+ return val === "ws" || val === "websocket";
28
+ }
29
+
30
+ /** Find a free TCP port in the given range by attempting to listen on each. */
31
+ async function findFreePort(
32
+ start = 4500,
33
+ end = 4600,
34
+ isReserved?: (port: number) => boolean,
35
+ ): Promise<number> {
36
+ for (let port = start; port <= end; port++) {
37
+ if (isReserved?.(port)) continue;
38
+ try {
39
+ const server = Bun.listen({
40
+ hostname: "127.0.0.1",
41
+ port,
42
+ socket: {
43
+ data() {},
44
+ open() {},
45
+ close() {},
46
+ },
47
+ });
48
+ server.stop(true);
49
+ return port;
50
+ } catch {
51
+ // Port in use, try next
52
+ }
53
+ }
54
+ throw new Error(`No free port found in range ${start}-${end}`);
55
+ }
56
+
57
+ function sanitizeSpawnArgsForLog(args: string[]): string {
58
+ const secretKeyPattern = /(token|key|secret|password)/i;
59
+ const out = [...args];
60
+ for (let i = 0; i < out.length; i++) {
61
+ if (out[i] === "-e" && i + 1 < out.length) {
62
+ const envPair = out[i + 1];
63
+ const eqIdx = envPair.indexOf("=");
64
+ if (eqIdx > 0) {
65
+ const k = envPair.slice(0, eqIdx);
66
+ if (secretKeyPattern.test(k)) {
67
+ out[i + 1] = `${k}=***`;
68
+ }
69
+ }
70
+ }
71
+ }
72
+ return out.join(" ");
73
+ }
74
+
75
+ const CODEX_WS_PROXY_PATH = fileURLToPath(new URL("./codex-ws-proxy.cjs", import.meta.url));
76
+ const CODEX_CONTAINER_WS_PORT = Number(process.env.HEYHANK_CODEX_CONTAINER_WS_PORT || process.env.COMPANION_CODEX_CONTAINER_WS_PORT || "4502");
77
+
78
+ export interface SdkSessionInfo {
79
+ sessionId: string;
80
+ pid?: number;
81
+ state: "starting" | "connected" | "running" | "exited";
82
+ exitCode?: number | null;
83
+ model?: string;
84
+ permissionMode?: string;
85
+ cwd: string;
86
+ createdAt: number;
87
+ /** The CLI's internal session ID (from system.init), used for --resume */
88
+ cliSessionId?: string;
89
+ archived?: boolean;
90
+ /** User-facing session name */
91
+ name?: string;
92
+ /** Which backend this session uses */
93
+ backendType?: BackendType;
94
+ /** Git branch from bridge state (enriched by REST API) */
95
+ gitBranch?: string;
96
+ /** Git ahead count (enriched by REST API) */
97
+ gitAhead?: number;
98
+ /** Git behind count (enriched by REST API) */
99
+ gitBehind?: number;
100
+ /** Total lines added (enriched by REST API) */
101
+ totalLinesAdded?: number;
102
+ /** Total lines removed (enriched by REST API) */
103
+ totalLinesRemoved?: number;
104
+ /** Whether internet/web search is enabled for Codex sessions */
105
+ codexInternetAccess?: boolean;
106
+ /** Sandbox mode selected for Codex sessions */
107
+ codexSandbox?: "workspace-write" | "danger-full-access";
108
+ /** If this session was spawned by a cron job */
109
+ cronJobId?: string;
110
+ /** Human-readable name of the cron job that spawned this session */
111
+ cronJobName?: string;
112
+ /** If session was created from an existing Claude thread/session. */
113
+ resumeSessionAt?: string;
114
+ /** Whether the resumed session used --fork-session. */
115
+ forkSession?: boolean;
116
+ /** If this session was spawned by an agent */
117
+ agentId?: string;
118
+ /** Human-readable name of the agent that spawned this session */
119
+ agentName?: string;
120
+ /** Sandbox profile slug used for this session */
121
+ sandboxSlug?: string;
122
+
123
+ // Codex WebSocket transport fields
124
+ /** Port used for Codex WebSocket transport (host mode). */
125
+ codexWsPort?: number;
126
+ /** Full WebSocket URL for the Codex app-server. */
127
+ codexWsUrl?: string;
128
+
129
+ // Container fields
130
+ /** Docker container ID when session runs inside a container */
131
+ containerId?: string;
132
+ /** Docker container name */
133
+ containerName?: string;
134
+ /** Docker image used for the container */
135
+ containerImage?: string;
136
+ /** Runtime cwd inside container for agent RPC calls (e.g. "/workspace"). */
137
+ containerCwd?: string;
138
+ }
139
+
140
+ export interface LaunchOptions {
141
+ model?: string;
142
+ permissionMode?: string;
143
+ cwd?: string;
144
+ claudeBinary?: string;
145
+ codexBinary?: string;
146
+ allowedTools?: string[];
147
+ env?: Record<string, string>;
148
+ backendType?: BackendType;
149
+ /** Codex sandbox mode. */
150
+ codexSandbox?: "workspace-write" | "danger-full-access";
151
+ /** Whether Codex internet/web search should be enabled for this session. */
152
+ codexInternetAccess?: boolean;
153
+ /** Optional override for CODEX_HOME used by Codex sessions. */
154
+ codexHome?: string;
155
+ /** Docker container ID — when set, CLI runs inside container via docker exec */
156
+ containerId?: string;
157
+ /** Docker container name */
158
+ containerName?: string;
159
+ /** Docker image used for the container */
160
+ containerImage?: string;
161
+ /** Runtime cwd inside the container (typically "/workspace"). */
162
+ containerCwd?: string;
163
+ /** Start from a specific prior Claude session/thread point. */
164
+ resumeSessionAt?: string;
165
+ /** Fork a new Claude session when resuming from prior context. */
166
+ forkSession?: boolean;
167
+ /** Optional system prompt to inject into Codex sessions. */
168
+ systemPrompt?: string;
169
+ /** Claude Code --provider flag value (e.g. "openrouter", "mistral") */
170
+ provider?: string;
171
+ /** Sandbox profile slug used for this session */
172
+ sandboxSlug?: string;
173
+ }
174
+
175
+ /**
176
+ * Manages CLI backend processes (Claude Code via --sdk-url WebSocket,
177
+ * or Codex via app-server stdio/WebSocket).
178
+ */
179
+ export class CliLauncher {
180
+ private sessions = new Map<string, SdkSessionInfo>();
181
+ private processes = new Map<string, Subprocess>();
182
+ /** Sidecar Node proxy processes used by Codex WebSocket transport. */
183
+ private codexWsProxies = new Map<string, Subprocess>();
184
+ /** Host-mode Codex WS listen ports currently reserved by active sessions. */
185
+ private claimedCodexWsPorts = new Set<number>();
186
+ /** Runtime-only env vars per session (kept out of persisted launcher state). */
187
+ private sessionEnvs = new Map<string, Record<string, string>>();
188
+ private port: number;
189
+ private store: SessionStore | null = null;
190
+ private recorder: RecorderManager | null = null;
191
+ constructor(port: number) {
192
+ this.port = port;
193
+ }
194
+
195
+ /** Attach a persistent store for surviving server restarts. */
196
+ setStore(store: SessionStore): void {
197
+ this.store = store;
198
+ }
199
+
200
+ /** Attach a recorder for raw message capture. */
201
+ setRecorder(recorder: RecorderManager): void {
202
+ this.recorder = recorder;
203
+ }
204
+
205
+ /** Persist launcher state to disk. */
206
+ private persistState(): void {
207
+ if (!this.store) return;
208
+ const data = Array.from(this.sessions.values());
209
+ this.store.saveLauncher(data);
210
+ }
211
+
212
+ private claimCodexWsPort(port: number): void {
213
+ this.claimedCodexWsPorts.add(port);
214
+ }
215
+
216
+ private releaseCodexWsPort(info: SdkSessionInfo | undefined): void {
217
+ if (!info || info.containerId) return;
218
+ if (typeof info.codexWsPort !== "number") return;
219
+ this.claimedCodexWsPorts.delete(info.codexWsPort);
220
+ info.codexWsPort = undefined;
221
+ info.codexWsUrl = undefined;
222
+ }
223
+
224
+ /**
225
+ * Restore sessions from disk and check which PIDs are still alive.
226
+ * Returns the number of recovered sessions.
227
+ */
228
+ restoreFromDisk(): number {
229
+ if (!this.store) return 0;
230
+ const data = this.store.loadLauncher<SdkSessionInfo[]>();
231
+ if (!data || !Array.isArray(data)) return 0;
232
+
233
+ let recovered = 0;
234
+ for (const info of data) {
235
+ if (this.sessions.has(info.sessionId)) continue;
236
+
237
+ // Check if the process is still alive
238
+ if (info.state !== "exited") {
239
+ if (info.containerId && info.codexWsPort) {
240
+ // Docker WS mode: the stored PID is `docker exec -d` which exits
241
+ // immediately after launch. Check container liveness instead.
242
+ const containerState = containerManager.isContainerAlive(info.containerId);
243
+ if (containerState === "running") {
244
+ info.state = "starting";
245
+ this.sessions.set(info.sessionId, info);
246
+ recovered++;
247
+ } else {
248
+ info.state = "exited";
249
+ info.exitCode = -1;
250
+ this.sessions.set(info.sessionId, info);
251
+ }
252
+ } else if (info.pid) {
253
+ try {
254
+ process.kill(info.pid, 0); // signal 0 = just check if alive
255
+ info.state = "starting"; // WS not yet re-established, wait for CLI to reconnect
256
+ this.sessions.set(info.sessionId, info);
257
+ recovered++;
258
+ } catch {
259
+ // Process is dead
260
+ info.state = "exited";
261
+ info.exitCode = -1;
262
+ this.sessions.set(info.sessionId, info);
263
+ }
264
+ } else {
265
+ this.sessions.set(info.sessionId, info);
266
+ }
267
+ } else {
268
+ // Already exited
269
+ this.sessions.set(info.sessionId, info);
270
+ }
271
+
272
+ // Avoid reusing ports already owned by recovered host-mode Codex sessions.
273
+ if (
274
+ info.backendType === "codex"
275
+ && !info.containerId
276
+ && info.state !== "exited"
277
+ && typeof info.codexWsPort === "number"
278
+ ) {
279
+ this.claimCodexWsPort(info.codexWsPort);
280
+ }
281
+ }
282
+ if (recovered > 0) {
283
+ console.log(`[cli-launcher] Recovered ${recovered} live session(s) from disk`);
284
+ }
285
+ return recovered;
286
+ }
287
+
288
+ /**
289
+ * Launch a new CLI session (Claude Code or Codex).
290
+ */
291
+ launch(options: LaunchOptions = {}): SdkSessionInfo {
292
+ const sessionId = randomUUID();
293
+ const cwd = options.cwd || process.cwd();
294
+ const backendType = options.backendType || "claude";
295
+
296
+ const info: SdkSessionInfo = {
297
+ sessionId,
298
+ state: "starting",
299
+ model: options.model,
300
+ permissionMode: options.permissionMode,
301
+ cwd,
302
+ createdAt: Date.now(),
303
+ backendType,
304
+ };
305
+
306
+ if (options.resumeSessionAt) {
307
+ info.resumeSessionAt = options.resumeSessionAt;
308
+ info.forkSession = options.forkSession === true;
309
+ }
310
+
311
+ if (backendType === "codex") {
312
+ info.codexInternetAccess = options.codexInternetAccess === true;
313
+ info.codexSandbox = options.codexSandbox;
314
+ }
315
+
316
+ // Store sandbox slug if provided
317
+ if (options.sandboxSlug) {
318
+ info.sandboxSlug = options.sandboxSlug;
319
+ }
320
+
321
+ // Store container metadata if provided
322
+ if (options.containerId) {
323
+ info.containerId = options.containerId;
324
+ info.containerName = options.containerName;
325
+ info.containerImage = options.containerImage;
326
+ info.containerCwd = options.containerCwd || "/workspace";
327
+ }
328
+
329
+ this.sessions.set(sessionId, info);
330
+ if (options.env) {
331
+ this.sessionEnvs.set(sessionId, { ...options.env });
332
+ }
333
+
334
+ if (backendType === "codex") {
335
+ this.spawnCodex(sessionId, info, options);
336
+ } else {
337
+ this.spawnCLI(sessionId, info, options);
338
+ }
339
+ return info;
340
+ }
341
+
342
+ /**
343
+ * Relaunch a CLI process for an existing session.
344
+ * Kills the old process if still alive, then spawns a fresh CLI
345
+ * that connects back to the same session in the WsBridge.
346
+ */
347
+ async relaunch(sessionId: string): Promise<{ ok: boolean; error?: string }> {
348
+ const info = this.sessions.get(sessionId);
349
+ if (!info) return { ok: false, error: "Session not found" };
350
+
351
+ // Kill old process(es) if still alive.
352
+ // Snapshot both handles first because killing the proxy can trigger the
353
+ // WS session exit handler, which clears `this.processes`.
354
+ const oldProc = this.processes.get(sessionId);
355
+ const oldProxy = this.codexWsProxies.get(sessionId);
356
+ if (oldProxy) {
357
+ try {
358
+ oldProxy.kill("SIGTERM");
359
+ await Promise.race([
360
+ oldProxy.exited,
361
+ new Promise((r) => setTimeout(r, 2000)),
362
+ ]);
363
+ } catch {}
364
+ this.codexWsProxies.delete(sessionId);
365
+ }
366
+ if (oldProc) {
367
+ try {
368
+ oldProc.kill("SIGTERM");
369
+ await Promise.race([
370
+ oldProc.exited,
371
+ new Promise((r) => setTimeout(r, 2000)),
372
+ ]);
373
+ } catch {}
374
+ this.processes.delete(sessionId);
375
+ } else if (info.pid) {
376
+ // Process from a previous server instance — kill by PID
377
+ try { process.kill(info.pid, "SIGTERM"); } catch {}
378
+ }
379
+
380
+ // Release any host-mode Codex port claim before picking a new one.
381
+ this.releaseCodexWsPort(info);
382
+
383
+ // Pre-flight validation for containerized sessions
384
+ if (info.containerId) {
385
+ const containerLabel = info.containerName || info.containerId.slice(0, 12);
386
+ const containerState = containerManager.isContainerAlive(info.containerId);
387
+
388
+ if (containerState === "missing") {
389
+ console.error(`[cli-launcher] Container ${containerLabel} no longer exists for session ${sessionId}`);
390
+ info.state = "exited";
391
+ info.exitCode = 1;
392
+ this.persistState();
393
+ return {
394
+ ok: false,
395
+ error: `Container "${containerLabel}" was removed externally. Please create a new session.`,
396
+ };
397
+ }
398
+
399
+ if (containerState === "stopped") {
400
+ try {
401
+ containerManager.startContainer(info.containerId);
402
+ console.log(`[cli-launcher] Restarted stopped container ${containerLabel} for session ${sessionId}`);
403
+ } catch (e) {
404
+ info.state = "exited";
405
+ info.exitCode = 1;
406
+ this.persistState();
407
+ return {
408
+ ok: false,
409
+ error: `Container "${containerLabel}" is stopped and could not be restarted: ${e instanceof Error ? e.message : String(e)}`,
410
+ };
411
+ }
412
+ }
413
+
414
+ // Validate the CLI binary exists inside the container
415
+ const binary = info.backendType === "codex" ? "codex" : "claude";
416
+ if (!containerManager.hasBinaryInContainer(info.containerId, binary)) {
417
+ console.error(`[cli-launcher] "${binary}" not found in container ${containerLabel} for session ${sessionId}`);
418
+ info.state = "exited";
419
+ info.exitCode = 127;
420
+ this.persistState();
421
+ return {
422
+ ok: false,
423
+ error: `"${binary}" command not found inside container "${containerLabel}". The container image may need to be rebuilt.`,
424
+ };
425
+ }
426
+ }
427
+
428
+ info.state = "starting";
429
+
430
+ const runtimeEnv = this.sessionEnvs.get(sessionId);
431
+
432
+ if (info.backendType === "codex") {
433
+ this.spawnCodex(sessionId, info, {
434
+ model: info.model,
435
+ permissionMode: info.permissionMode,
436
+ cwd: info.cwd,
437
+ codexSandbox: info.codexSandbox,
438
+ codexInternetAccess: info.codexInternetAccess,
439
+ containerId: info.containerId,
440
+ containerName: info.containerName,
441
+ containerImage: info.containerImage,
442
+ containerCwd: info.containerCwd,
443
+ env: runtimeEnv,
444
+ });
445
+ } else {
446
+ this.spawnCLI(sessionId, info, {
447
+ model: info.model,
448
+ permissionMode: info.permissionMode,
449
+ cwd: info.cwd,
450
+ resumeSessionId: info.cliSessionId,
451
+ containerId: info.containerId,
452
+ containerName: info.containerName,
453
+ containerImage: info.containerImage,
454
+ env: runtimeEnv,
455
+ });
456
+ }
457
+ return { ok: true };
458
+ }
459
+
460
+ /**
461
+ * Get all sessions in "starting" state (awaiting CLI WebSocket connection).
462
+ */
463
+ getStartingSessions(): SdkSessionInfo[] {
464
+ return Array.from(this.sessions.values()).filter((s) => s.state === "starting");
465
+ }
466
+
467
+ private spawnCLI(sessionId: string, info: SdkSessionInfo, options: LaunchOptions & { resumeSessionId?: string }): void {
468
+ const isContainerized = !!options.containerId;
469
+
470
+ // For containerized sessions, the CLI binary lives inside the container.
471
+ // For host sessions, resolve the binary on the host.
472
+ let binary = options.claudeBinary || "claude";
473
+ if (!isContainerized) {
474
+ const resolved = resolveBinary(binary);
475
+ if (resolved) {
476
+ binary = resolved;
477
+ } else {
478
+ console.error(`[cli-launcher] Binary "${binary}" not found in PATH`);
479
+ info.state = "exited";
480
+ info.exitCode = 127;
481
+ this.persistState();
482
+ return;
483
+ }
484
+ }
485
+
486
+ // Allow overriding the host alias used by containerized Claude sessions.
487
+ // Useful when host.docker.internal is unavailable in a given Docker setup.
488
+ const containerSdkHost = (process.env.HEYHANK_CONTAINER_SDK_HOST || process.env.COMPANION_CONTAINER_SDK_HOST || "host.docker.internal").trim()
489
+ || "host.docker.internal";
490
+
491
+ // When running inside a container, the SDK URL should target the host alias
492
+ // so the CLI can connect back to the Hono server running on the host.
493
+ const sdkUrl = isContainerized
494
+ ? `ws://${containerSdkHost}:${this.port}/ws/cli/${sessionId}`
495
+ : `ws://localhost:${this.port}/ws/cli/${sessionId}`;
496
+
497
+ // Claude Code rejects bypassPermissions when running with root/sudo.
498
+ // Container sessions are downgraded by default; host sessions are only
499
+ // downgraded when this server itself runs as root.
500
+ let effectivePermissionMode = options.permissionMode;
501
+ const isRootProcess = typeof process.getuid === "function" && process.getuid() === 0;
502
+ const shouldDowngradeContainerBypass =
503
+ isContainerized
504
+ && options.permissionMode === "bypassPermissions"
505
+ && (process.env.HEYHANK_FORCE_BYPASS_IN_CONTAINER || process.env.COMPANION_FORCE_BYPASS_IN_CONTAINER) !== "1";
506
+ const shouldDowngradeRootBypass =
507
+ !isContainerized
508
+ && isRootProcess
509
+ && options.permissionMode === "bypassPermissions"
510
+ && (process.env.HEYHANK_FORCE_BYPASS_AS_ROOT || process.env.COMPANION_FORCE_BYPASS_AS_ROOT) !== "1";
511
+
512
+ if (shouldDowngradeContainerBypass || shouldDowngradeRootBypass) {
513
+ const scope = isContainerized ? "container" : "root";
514
+ console.warn(
515
+ `[cli-launcher] Session ${sessionId}: downgrading ${scope} permission mode ` +
516
+ `from bypassPermissions to acceptEdits.`,
517
+ );
518
+ effectivePermissionMode = "acceptEdits";
519
+ info.permissionMode = "acceptEdits";
520
+ }
521
+
522
+ const args: string[] = [
523
+ "--sdk-url", sdkUrl,
524
+ "--print",
525
+ "--output-format", "stream-json",
526
+ "--input-format", "stream-json",
527
+ // Required on newer Claude Code versions to emit streaming chunk events.
528
+ "--include-partial-messages",
529
+ "--verbose",
530
+ ];
531
+
532
+ if (options.model) {
533
+ args.push("--model", options.model);
534
+ }
535
+ if (options.provider && options.provider !== "anthropic") {
536
+ args.push("--provider", options.provider);
537
+ }
538
+ if (effectivePermissionMode) {
539
+ args.push("--permission-mode", effectivePermissionMode);
540
+ }
541
+ if (options.allowedTools) {
542
+ for (const tool of options.allowedTools) {
543
+ args.push("--allowedTools", tool);
544
+ }
545
+ }
546
+ if (options.resumeSessionAt) {
547
+ args.push("--resume-session-at", options.resumeSessionAt);
548
+ }
549
+ if (options.forkSession) {
550
+ args.push("--fork-session");
551
+ }
552
+
553
+ // Always pass -p "" for headless mode. When relaunching, also pass --resume
554
+ // to restore the CLI's conversation context.
555
+ if (options.resumeSessionId) {
556
+ args.push("--resume", options.resumeSessionId);
557
+ }
558
+
559
+ args.push("-p", "");
560
+
561
+ let spawnCmd: string[];
562
+ let spawnEnv: Record<string, string | undefined>;
563
+ let spawnCwd: string | undefined;
564
+
565
+ if (isContainerized) {
566
+ // Run CLI inside the container via docker exec -i.
567
+ // Keeping stdin open avoids premature EOF-driven exits in SDK mode.
568
+ // Environment variables are passed via -e flags to docker exec.
569
+ const dockerArgs = ["docker", "exec", "-i"];
570
+
571
+ // Pass env vars via -e flags
572
+ if (options.env) {
573
+ for (const [k, v] of Object.entries(options.env)) {
574
+ dockerArgs.push("-e", `${k}=${v}`);
575
+ }
576
+ }
577
+ // Ensure CLAUDECODE is unset inside container
578
+ dockerArgs.push("-e", "CLAUDECODE=");
579
+
580
+ dockerArgs.push(options.containerId!);
581
+ // Use a login shell so ~/.bashrc is sourced and nvm/bun/deno/etc are on PATH
582
+ const innerCmd = [binary, ...args].map(a => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
583
+ dockerArgs.push("bash", "-lc", innerCmd);
584
+
585
+ spawnCmd = dockerArgs;
586
+ // Host env for the docker CLI itself
587
+ spawnEnv = { ...process.env, PATH: getEnrichedPath() };
588
+ spawnCwd = undefined; // cwd is set inside the container via -w at creation
589
+ } else {
590
+ // Host-based spawn (original behavior)
591
+ // On Windows, .cmd/.bat files cannot be spawned directly by Bun.spawn;
592
+ // they must be invoked via cmd.exe /c.
593
+ const isCmdScript = process.platform === "win32" && (binary.endsWith(".cmd") || binary.endsWith(".bat"));
594
+ spawnCmd = isCmdScript ? ["cmd.exe", "/c", binary, ...args] : [binary, ...args];
595
+ spawnEnv = {
596
+ ...process.env,
597
+ CLAUDECODE: undefined,
598
+ ...options.env,
599
+ PATH: getEnrichedPath(),
600
+ };
601
+ spawnCwd = info.cwd;
602
+ }
603
+
604
+ console.log(
605
+ `[cli-launcher] Spawning session ${sessionId}${isContainerized ? " (container)" : ""}: ` +
606
+ sanitizeSpawnArgsForLog(spawnCmd),
607
+ );
608
+
609
+ const proc = Bun.spawn(spawnCmd, {
610
+ cwd: spawnCwd,
611
+ env: spawnEnv,
612
+ stdout: "pipe",
613
+ stderr: "pipe",
614
+ });
615
+
616
+ info.pid = proc.pid;
617
+ this.processes.set(sessionId, proc);
618
+
619
+ // Stream stdout/stderr for debugging
620
+ this.pipeOutput(sessionId, proc);
621
+
622
+ // Monitor process exit
623
+ const spawnedAt = Date.now();
624
+ proc.exited.then((exitCode) => {
625
+ console.log(`[cli-launcher] Session ${sessionId} exited (code=${exitCode})`);
626
+ const session = this.sessions.get(sessionId);
627
+ if (session) {
628
+ session.state = "exited";
629
+ session.exitCode = exitCode;
630
+
631
+ // If the process exited almost immediately with --resume, the resume likely failed.
632
+ // Clear cliSessionId so the next relaunch starts fresh.
633
+ const uptime = Date.now() - spawnedAt;
634
+ if (uptime < 5000 && options.resumeSessionId) {
635
+ console.error(`[cli-launcher] Session ${sessionId} exited immediately after --resume (${uptime}ms). Clearing cliSessionId for fresh start.`);
636
+ session.cliSessionId = undefined;
637
+ }
638
+ }
639
+ this.processes.delete(sessionId);
640
+ this.persistState();
641
+ heyHankBus.emit("session:exited", { sessionId, exitCode });
642
+ });
643
+
644
+ this.persistState();
645
+ }
646
+
647
+ /**
648
+ * Spawn a Codex app-server subprocess for a session.
649
+ * Transport (stdio vs WebSocket) is selected by `HEYHANK_CODEX_TRANSPORT`.
650
+ */
651
+ private prepareCodexHome(codexHome: string): void {
652
+ mkdirSync(codexHome, { recursive: true });
653
+
654
+ const legacyHome = getLegacyCodexHome();
655
+ if (resolve(legacyHome) === resolve(codexHome) || !existsSync(legacyHome)) {
656
+ return;
657
+ }
658
+
659
+ // Bootstrap only the user-level artifacts Codex needs (auth/config/skills),
660
+ // while intentionally skipping sessions/sqlite to avoid stale rollout indexes.
661
+ const fileSeeds = ["auth.json", "config.toml", "models_cache.json", "version.json"];
662
+ for (const name of fileSeeds) {
663
+ try {
664
+ const src = join(legacyHome, name);
665
+ const dest = join(codexHome, name);
666
+ if (!existsSync(dest) && existsSync(src)) {
667
+ copyFileSync(src, dest);
668
+ }
669
+ } catch (e) {
670
+ console.warn(`[cli-launcher] Failed to bootstrap ${name} from legacy home:`, e);
671
+ }
672
+ }
673
+
674
+ const dirSeeds = ["skills", "vendor_imports", "prompts", "rules"];
675
+ for (const name of dirSeeds) {
676
+ try {
677
+ const src = join(legacyHome, name);
678
+ const dest = join(codexHome, name);
679
+ if (!existsSync(dest) && existsSync(src)) {
680
+ cpSync(src, dest, { recursive: true, dereference: true });
681
+ }
682
+ } catch (e) {
683
+ console.warn(`[cli-launcher] Failed to bootstrap ${name}/ from legacy home:`, e);
684
+ }
685
+ }
686
+ }
687
+
688
+ private spawnCodex(sessionId: string, info: SdkSessionInfo, options: LaunchOptions): void {
689
+ const useWs = isCodexWsTransportEnabled();
690
+ if (useWs) {
691
+ this.spawnCodexWs(sessionId, info, options);
692
+ } else {
693
+ this.spawnCodexStdio(sessionId, info, options);
694
+ }
695
+ }
696
+
697
+ /**
698
+ * Spawn Codex with WebSocket transport.
699
+ * Codex listens on `ws://127.0.0.1:PORT`, HeyHank connects as a client.
700
+ */
701
+ private async spawnCodexWs(sessionId: string, info: SdkSessionInfo, options: LaunchOptions): Promise<void> {
702
+ const isContainerized = !!options.containerId;
703
+ const connectTimeoutMs = Math.max(1000, parseInt(process.env.HEYHANK_CODEX_WS_CONNECT_TIMEOUT_MS ?? process.env.COMPANION_CODEX_WS_CONNECT_TIMEOUT_MS ?? "", 10) || 30000);
704
+ const pongTimeoutMs = Math.max(1000, parseInt(process.env.HEYHANK_CODEX_PONG_TIMEOUT_MS ?? process.env.COMPANION_CODEX_PONG_TIMEOUT_MS ?? "", 10) || 30000);
705
+
706
+ let binary = options.codexBinary || "codex";
707
+ if (!isContainerized) {
708
+ const resolved = resolveBinary(binary);
709
+ if (resolved) {
710
+ binary = resolved;
711
+ } else {
712
+ console.error(`[cli-launcher] Binary "${binary}" not found in PATH`);
713
+ info.state = "exited";
714
+ info.exitCode = 127;
715
+ this.persistState();
716
+ return;
717
+ }
718
+ }
719
+
720
+ // Host mode: choose a free host port. Container mode: use a fixed container port
721
+ // and connect via the container's mapped host port.
722
+ let codexListenPort: number;
723
+ let proxyConnectPort: number;
724
+ if (isContainerized) {
725
+ codexListenPort = CODEX_CONTAINER_WS_PORT;
726
+ const containerInfo = containerManager.getContainerById(options.containerId!);
727
+ const mappedPort = containerInfo?.portMappings.find((p) => p.containerPort === CODEX_CONTAINER_WS_PORT)?.hostPort;
728
+ if (!mappedPort) {
729
+ console.error(
730
+ `[cli-launcher] Missing port mapping for Codex container port ${CODEX_CONTAINER_WS_PORT} ` +
731
+ `on container ${options.containerId}`,
732
+ );
733
+ info.state = "exited";
734
+ info.exitCode = 1;
735
+ this.persistState();
736
+ return;
737
+ }
738
+ proxyConnectPort = mappedPort;
739
+ } else {
740
+ try {
741
+ proxyConnectPort = await findFreePort(
742
+ 4500,
743
+ 4600,
744
+ (port) => this.claimedCodexWsPorts.has(port),
745
+ );
746
+ this.claimCodexWsPort(proxyConnectPort);
747
+ // Set immediately after claiming so any downstream failure can release it.
748
+ info.codexWsPort = proxyConnectPort;
749
+ } catch (err) {
750
+ console.error(`[cli-launcher] Failed to find free port for Codex WS: ${err}`);
751
+ info.state = "exited";
752
+ info.exitCode = 1;
753
+ this.persistState();
754
+ return;
755
+ }
756
+ codexListenPort = proxyConnectPort;
757
+ }
758
+
759
+ const listenAddr = isContainerized
760
+ ? `ws://0.0.0.0:${codexListenPort}`
761
+ : `ws://127.0.0.1:${codexListenPort}`;
762
+
763
+ const args: string[] = ["app-server", "--listen", listenAddr];
764
+ // Enable Codex multi-agent mode by default (product decision).
765
+ args.push("--enable", "multi_agent");
766
+ const internetEnabled = options.codexInternetAccess !== false;
767
+ args.push("-c", `tools.webSearch=${internetEnabled ? "true" : "false"}`);
768
+ const codexHome = resolveHeyHankCodexSessionHome(
769
+ sessionId,
770
+ options.codexHome,
771
+ );
772
+ if (!isContainerized) {
773
+ this.prepareCodexHome(codexHome);
774
+ }
775
+
776
+ let spawnCmd: string[];
777
+ let spawnEnv: Record<string, string | undefined>;
778
+ let spawnCwd: string | undefined;
779
+
780
+ if (isContainerized) {
781
+ // Run Codex inside the container via docker exec -d (detached, no stdin pipe needed)
782
+ const dockerArgs = ["docker", "exec", "-d"];
783
+ if (options.env) {
784
+ for (const [k, v] of Object.entries(options.env)) {
785
+ dockerArgs.push("-e", `${k}=${v}`);
786
+ }
787
+ }
788
+ dockerArgs.push("-e", "CLAUDECODE=");
789
+ dockerArgs.push("-e", "CODEX_HOME=/root/.codex");
790
+ dockerArgs.push(options.containerId!);
791
+ const innerCmd = [binary, ...args].map(a => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
792
+ dockerArgs.push("bash", "-lc", innerCmd);
793
+
794
+ spawnCmd = dockerArgs;
795
+ spawnEnv = { ...process.env, PATH: getEnrichedPath() };
796
+ spawnCwd = undefined;
797
+ } else {
798
+ const binaryDir = resolve(binary, "..");
799
+ const siblingNode = join(binaryDir, "node");
800
+ const enrichedPath = getEnrichedPath();
801
+ const pathSep = process.platform === "win32" ? ";" : ":";
802
+ const spawnPath = [binaryDir, ...enrichedPath.split(pathSep)].filter(Boolean).join(pathSep);
803
+
804
+ if (existsSync(siblingNode)) {
805
+ let codexScript: string;
806
+ try {
807
+ codexScript = realpathSync(binary);
808
+ } catch {
809
+ codexScript = binary;
810
+ }
811
+ spawnCmd = [siblingNode, codexScript, ...args];
812
+ } else {
813
+ // On Windows, .cmd/.bat files cannot be spawned directly by Bun.spawn
814
+ const isCmdScript = process.platform === "win32" && (binary.endsWith(".cmd") || binary.endsWith(".bat"));
815
+ spawnCmd = isCmdScript ? ["cmd.exe", "/c", binary, ...args] : [binary, ...args];
816
+ }
817
+
818
+ spawnEnv = {
819
+ ...process.env,
820
+ CLAUDECODE: undefined,
821
+ ...options.env,
822
+ CODEX_HOME: codexHome,
823
+ PATH: spawnPath,
824
+ };
825
+ spawnCwd = info.cwd;
826
+ }
827
+
828
+ console.log(
829
+ `[cli-launcher] Spawning Codex WS session ${sessionId}${isContainerized ? " (container)" : ""}: ` +
830
+ sanitizeSpawnArgsForLog(spawnCmd),
831
+ );
832
+
833
+ const proc = Bun.spawn(spawnCmd, {
834
+ cwd: spawnCwd,
835
+ env: spawnEnv,
836
+ stdin: "ignore",
837
+ stdout: "pipe",
838
+ stderr: "pipe",
839
+ });
840
+
841
+ info.pid = proc.pid;
842
+ this.processes.set(sessionId, proc);
843
+
844
+ // Pipe stdout/stderr for debugging (JSON-RPC goes over WebSocket now)
845
+ this.pipeOutput(sessionId, proc);
846
+
847
+ // Store WS metadata
848
+ const wsUrl = `ws://127.0.0.1:${proxyConnectPort}`;
849
+ if (typeof info.codexWsPort !== "number") {
850
+ info.codexWsPort = proxyConnectPort;
851
+ }
852
+ info.codexWsUrl = wsUrl;
853
+
854
+ // Connect to Codex app-server through a Node helper process that uses the
855
+ // `ws` package directly (with perMessageDeflate disabled). This avoids a Bun
856
+ // runtime compatibility issue where the `ws` client can mis-handle a valid
857
+ // 101 upgrade response from Codex's Rust WS server.
858
+ const codexBinaryDir = isContainerized ? undefined : resolve(binary, "..");
859
+ const proxyNodeCandidate = codexBinaryDir ? join(codexBinaryDir, "node") : undefined;
860
+ const proxyNode = proxyNodeCandidate && existsSync(proxyNodeCandidate) ? proxyNodeCandidate : "node";
861
+ const proxyProc = Bun.spawn([proxyNode, CODEX_WS_PROXY_PATH, wsUrl, String(connectTimeoutMs), String(pongTimeoutMs)], {
862
+ cwd: info.cwd,
863
+ env: {
864
+ ...process.env,
865
+ PATH: getEnrichedPath(),
866
+ },
867
+ stdin: "pipe",
868
+ stdout: "pipe",
869
+ stderr: "pipe",
870
+ });
871
+ this.codexWsProxies.set(sessionId, proxyProc);
872
+ // proxy stdout is the JSON-RPC protocol stream (consumed by CodexAdapter).
873
+ // Only pipe stderr for diagnostics to avoid locking stdout.
874
+ const proxyStderr = proxyProc.stderr;
875
+ if (proxyStderr && typeof proxyStderr !== "number") {
876
+ this.pipeStream(sessionId, proxyStderr, "stderr");
877
+ }
878
+
879
+ // Create CodexAdapter using stdio transport to the proxy process.
880
+ const adapter = new CodexAdapter(proxyProc, sessionId, {
881
+ model: options.model,
882
+ cwd: info.cwd,
883
+ executionCwd: options.containerId ? (info.containerCwd || "/workspace") : info.cwd,
884
+ approvalMode: options.permissionMode,
885
+ threadId: info.cliSessionId,
886
+ sandbox: options.codexSandbox,
887
+ recorder: this.recorder ?? undefined,
888
+ systemPrompt: options.systemPrompt,
889
+ killProcess: async () => {
890
+ try {
891
+ proxyProc.kill("SIGTERM");
892
+ } catch {}
893
+ try {
894
+ proc.kill("SIGTERM");
895
+ } catch {}
896
+ await Promise.race([
897
+ Promise.allSettled([proxyProc.exited, proc.exited]),
898
+ new Promise((r) => setTimeout(r, 5000)),
899
+ ]);
900
+ },
901
+ });
902
+
903
+ // Handle init errors
904
+ adapter.onInitError((error) => {
905
+ console.error(`[cli-launcher] Codex WS session ${sessionId} init failed: ${error}`);
906
+ try { proxyProc.kill("SIGTERM"); } catch {}
907
+ this.codexWsProxies.delete(sessionId);
908
+ const session = this.sessions.get(sessionId);
909
+ if (session) {
910
+ session.state = "exited";
911
+ session.exitCode = 1;
912
+ session.cliSessionId = undefined;
913
+ this.releaseCodexWsPort(session);
914
+ }
915
+ this.persistState();
916
+ });
917
+
918
+ // Notify the WsBridge to attach this adapter
919
+ heyHankBus.emit("backend:codex-adapter-created", { sessionId, adapter });
920
+
921
+ info.state = "connected";
922
+
923
+ // Monitor the proxy connection process as the primary transport liveness.
924
+ // In container mode, `docker exec -d` exits immediately after launching Codex
925
+ // and must not be treated as the backend process lifetime.
926
+ let exitHandled = false;
927
+ const handleWsSessionExit = (exitCode: number | null, source: "proxy" | "codex") => {
928
+ if (exitHandled) return;
929
+ exitHandled = true;
930
+ console.log(`[cli-launcher] Codex WS session ${sessionId} exited via ${source} (code=${exitCode})`);
931
+
932
+ // Notify the adapter that the transport is gone so it can clean up
933
+ // pending promises and stop accepting messages immediately.
934
+ adapter.handleTransportClose();
935
+
936
+ // Kill the other process too — if the proxy exits, kill Codex and vice versa.
937
+ // This prevents orphaned processes lingering after a partial crash.
938
+ // Note: The SIGTERM will cause the sibling to exit, which fires its own
939
+ // exit handler, but the `exitHandled` guard above ensures it's a no-op.
940
+ if (source === "proxy") {
941
+ try { proc.kill("SIGTERM"); } catch {}
942
+ } else {
943
+ try { proxyProc.kill("SIGTERM"); } catch {}
944
+ }
945
+
946
+ const session = this.sessions.get(sessionId);
947
+ if (session) {
948
+ session.state = "exited";
949
+ session.exitCode = exitCode;
950
+ this.releaseCodexWsPort(session);
951
+ }
952
+ this.processes.delete(sessionId);
953
+ this.codexWsProxies.delete(sessionId);
954
+ this.persistState();
955
+ heyHankBus.emit("session:exited", { sessionId, exitCode });
956
+ };
957
+
958
+ proxyProc.exited.then((exitCode) => {
959
+ handleWsSessionExit(exitCode, "proxy");
960
+ });
961
+
962
+ if (!isContainerized) {
963
+ proc.exited.then((exitCode) => {
964
+ handleWsSessionExit(exitCode, "codex");
965
+ });
966
+ } else {
967
+ proc.exited.then((exitCode) => {
968
+ // `docker exec -d` exits immediately after launch in container WS mode.
969
+ // Suppress the expected success case to avoid noisy logs; keep non-zero exits.
970
+ if (exitCode !== 0) {
971
+ console.warn(`[cli-launcher] Codex WS launcher command for ${sessionId} exited (code=${exitCode})`);
972
+ }
973
+ });
974
+ }
975
+
976
+ this.persistState();
977
+ }
978
+
979
+ /**
980
+ * Spawn Codex with stdio transport (legacy).
981
+ * Unlike Claude Code (which connects back via WebSocket), Codex uses stdin/stdout.
982
+ */
983
+ private spawnCodexStdio(sessionId: string, info: SdkSessionInfo, options: LaunchOptions): void {
984
+ const isContainerized = !!options.containerId;
985
+
986
+ let binary = options.codexBinary || "codex";
987
+ if (!isContainerized) {
988
+ const resolved = resolveBinary(binary);
989
+ if (resolved) {
990
+ binary = resolved;
991
+ } else {
992
+ console.error(`[cli-launcher] Binary "${binary}" not found in PATH`);
993
+ info.state = "exited";
994
+ info.exitCode = 127;
995
+ this.persistState();
996
+ return;
997
+ }
998
+ }
999
+
1000
+ const args: string[] = ["app-server"];
1001
+ // Enable Codex multi-agent mode by default (product decision).
1002
+ args.push("--enable", "multi_agent");
1003
+ const internetEnabled = options.codexInternetAccess !== false;
1004
+ args.push("-c", `tools.webSearch=${internetEnabled ? "true" : "false"}`);
1005
+ const codexHome = resolveHeyHankCodexSessionHome(
1006
+ sessionId,
1007
+ options.codexHome,
1008
+ );
1009
+ if (!isContainerized) {
1010
+ this.prepareCodexHome(codexHome);
1011
+ }
1012
+
1013
+ let spawnCmd: string[];
1014
+ let spawnEnv: Record<string, string | undefined>;
1015
+ let spawnCwd: string | undefined;
1016
+
1017
+ if (isContainerized) {
1018
+ // Run Codex inside the container via docker exec -i (stdin required for JSON-RPC)
1019
+ const dockerArgs = ["docker", "exec", "-i"];
1020
+ if (options.env) {
1021
+ for (const [k, v] of Object.entries(options.env)) {
1022
+ dockerArgs.push("-e", `${k}=${v}`);
1023
+ }
1024
+ }
1025
+ dockerArgs.push("-e", "CLAUDECODE=");
1026
+ // Point Codex at /root/.codex where container-manager seeded auth/config
1027
+ dockerArgs.push("-e", "CODEX_HOME=/root/.codex");
1028
+ dockerArgs.push(options.containerId!);
1029
+ // Use a login shell so ~/.bashrc is sourced and nvm/bun/deno/etc are on PATH
1030
+ const innerCmd = [binary, ...args].map(a => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
1031
+ dockerArgs.push("bash", "-lc", innerCmd);
1032
+
1033
+ spawnCmd = dockerArgs;
1034
+ spawnEnv = { ...process.env, PATH: getEnrichedPath() };
1035
+ spawnCwd = undefined;
1036
+ } else {
1037
+ // Host-based spawn — resolve node/shebang issues
1038
+ const binaryDir = resolve(binary, "..");
1039
+ const siblingNode = join(binaryDir, "node");
1040
+ const enrichedPath = getEnrichedPath();
1041
+ const pathSep = process.platform === "win32" ? ";" : ":";
1042
+ const spawnPath = [binaryDir, ...enrichedPath.split(pathSep)].filter(Boolean).join(pathSep);
1043
+
1044
+ if (existsSync(siblingNode)) {
1045
+ let codexScript: string;
1046
+ try {
1047
+ codexScript = realpathSync(binary);
1048
+ } catch {
1049
+ codexScript = binary;
1050
+ }
1051
+ spawnCmd = [siblingNode, codexScript, ...args];
1052
+ } else {
1053
+ // On Windows, .cmd/.bat files cannot be spawned directly by Bun.spawn
1054
+ const isCmdScript = process.platform === "win32" && (binary.endsWith(".cmd") || binary.endsWith(".bat"));
1055
+ spawnCmd = isCmdScript ? ["cmd.exe", "/c", binary, ...args] : [binary, ...args];
1056
+ }
1057
+
1058
+ spawnEnv = {
1059
+ ...process.env,
1060
+ CLAUDECODE: undefined,
1061
+ ...options.env,
1062
+ CODEX_HOME: codexHome,
1063
+ PATH: spawnPath,
1064
+ };
1065
+ spawnCwd = info.cwd;
1066
+ }
1067
+
1068
+ console.log(
1069
+ `[cli-launcher] Spawning Codex session ${sessionId}${isContainerized ? " (container)" : ""}: ` +
1070
+ sanitizeSpawnArgsForLog(spawnCmd),
1071
+ );
1072
+
1073
+ const proc = Bun.spawn(spawnCmd, {
1074
+ cwd: spawnCwd,
1075
+ env: spawnEnv,
1076
+ stdin: "pipe",
1077
+ stdout: "pipe",
1078
+ stderr: "pipe",
1079
+ });
1080
+
1081
+ info.pid = proc.pid;
1082
+ this.processes.set(sessionId, proc);
1083
+
1084
+ // Pipe stderr for debugging (stdout is used for JSON-RPC)
1085
+ const stderr = proc.stderr;
1086
+ if (stderr && typeof stderr !== "number") {
1087
+ this.pipeStream(sessionId, stderr, "stderr");
1088
+ }
1089
+
1090
+ // Create the CodexAdapter which handles JSON-RPC and message translation
1091
+ // Pass the raw permission mode — the adapter maps it to Codex's approval policy
1092
+ const adapter = new CodexAdapter(proc, sessionId, {
1093
+ model: options.model,
1094
+ cwd: info.cwd,
1095
+ executionCwd: options.containerId ? (info.containerCwd || "/workspace") : info.cwd,
1096
+ approvalMode: options.permissionMode,
1097
+ threadId: info.cliSessionId,
1098
+ sandbox: options.codexSandbox,
1099
+ recorder: this.recorder ?? undefined,
1100
+ systemPrompt: options.systemPrompt,
1101
+ });
1102
+
1103
+ // Handle init errors — mark session as exited so UI shows failure.
1104
+ // Also clear cliSessionId so the next relaunch starts a fresh thread
1105
+ // instead of trying to resume one whose rollout may be missing.
1106
+ adapter.onInitError((error) => {
1107
+ console.error(`[cli-launcher] Codex session ${sessionId} init failed: ${error}`);
1108
+ const session = this.sessions.get(sessionId);
1109
+ if (session) {
1110
+ session.state = "exited";
1111
+ session.exitCode = 1;
1112
+ session.cliSessionId = undefined;
1113
+ }
1114
+ this.persistState();
1115
+ });
1116
+
1117
+ // Notify the WsBridge to attach this adapter
1118
+ heyHankBus.emit("backend:codex-adapter-created", { sessionId, adapter });
1119
+
1120
+ // Mark as connected immediately (no WS handshake needed for stdio)
1121
+ info.state = "connected";
1122
+
1123
+ // Monitor process exit
1124
+ proc.exited.then((exitCode) => {
1125
+ console.log(`[cli-launcher] Codex session ${sessionId} exited (code=${exitCode})`);
1126
+ const session = this.sessions.get(sessionId);
1127
+ if (session) {
1128
+ session.state = "exited";
1129
+ session.exitCode = exitCode;
1130
+ }
1131
+ this.processes.delete(sessionId);
1132
+ this.persistState();
1133
+ heyHankBus.emit("session:exited", { sessionId, exitCode });
1134
+ });
1135
+
1136
+ this.persistState();
1137
+ }
1138
+
1139
+ /**
1140
+ * Mark a session as connected (called when CLI establishes WS connection).
1141
+ */
1142
+ markConnected(sessionId: string): void {
1143
+ const session = this.sessions.get(sessionId);
1144
+ if (session && (session.state === "starting" || session.state === "connected")) {
1145
+ session.state = "connected";
1146
+ console.log(`[cli-launcher] Session ${sessionId} connected via WebSocket`);
1147
+ this.persistState();
1148
+ }
1149
+ }
1150
+
1151
+ /**
1152
+ * Store the CLI's internal session ID (from system.init message).
1153
+ * This is needed for --resume on relaunch.
1154
+ */
1155
+ setCLISessionId(sessionId: string, cliSessionId: string): void {
1156
+ const session = this.sessions.get(sessionId);
1157
+ if (session) {
1158
+ session.cliSessionId = cliSessionId;
1159
+ this.persistState();
1160
+ }
1161
+ }
1162
+
1163
+ /**
1164
+ * Kill a session's CLI process.
1165
+ */
1166
+ async kill(sessionId: string): Promise<boolean> {
1167
+ const proxy = this.codexWsProxies.get(sessionId);
1168
+ if (proxy) {
1169
+ try { proxy.kill("SIGTERM"); } catch {}
1170
+ this.codexWsProxies.delete(sessionId);
1171
+ }
1172
+
1173
+ const proc = this.processes.get(sessionId);
1174
+ if (!proc) return !!proxy;
1175
+
1176
+ proc.kill("SIGTERM");
1177
+
1178
+ // Wait up to 5s for graceful exit, then force kill
1179
+ const exited = await Promise.race([
1180
+ proc.exited.then(() => true),
1181
+ new Promise<false>((resolve) => setTimeout(() => resolve(false), 5_000)),
1182
+ ]);
1183
+
1184
+ if (!exited) {
1185
+ console.log(`[cli-launcher] Force-killing session ${sessionId}`);
1186
+ proc.kill("SIGKILL");
1187
+ }
1188
+
1189
+ const session = this.sessions.get(sessionId);
1190
+ if (session) {
1191
+ session.state = "exited";
1192
+ session.exitCode = -1;
1193
+ this.releaseCodexWsPort(session);
1194
+ }
1195
+ this.processes.delete(sessionId);
1196
+ this.persistState();
1197
+ return true;
1198
+ }
1199
+
1200
+ /**
1201
+ * List all sessions (active + recently exited).
1202
+ */
1203
+ listSessions(): SdkSessionInfo[] {
1204
+ return Array.from(this.sessions.values());
1205
+ }
1206
+
1207
+ /**
1208
+ * Get a specific session.
1209
+ */
1210
+ getSession(sessionId: string): SdkSessionInfo | undefined {
1211
+ return this.sessions.get(sessionId);
1212
+ }
1213
+
1214
+ /**
1215
+ * Check if a session exists and is alive (not exited).
1216
+ */
1217
+ isAlive(sessionId: string): boolean {
1218
+ const session = this.sessions.get(sessionId);
1219
+ return !!session && session.state !== "exited";
1220
+ }
1221
+
1222
+ /**
1223
+ * Set the archived flag on a session.
1224
+ */
1225
+ setArchived(sessionId: string, archived: boolean): void {
1226
+ const info = this.sessions.get(sessionId);
1227
+ if (info) {
1228
+ info.archived = archived;
1229
+ this.persistState();
1230
+ }
1231
+ }
1232
+
1233
+ /**
1234
+ * Remove a session from the internal map (after kill or cleanup).
1235
+ */
1236
+ removeSession(sessionId: string) {
1237
+ this.releaseCodexWsPort(this.sessions.get(sessionId));
1238
+ this.sessions.delete(sessionId);
1239
+ this.processes.delete(sessionId);
1240
+ this.codexWsProxies.delete(sessionId);
1241
+ this.sessionEnvs.delete(sessionId);
1242
+ this.persistState();
1243
+ }
1244
+
1245
+ /**
1246
+ * Remove exited sessions from the list.
1247
+ */
1248
+ pruneExited(): number {
1249
+ let pruned = 0;
1250
+ for (const [id, session] of this.sessions) {
1251
+ if (session.state === "exited") {
1252
+ this.releaseCodexWsPort(session);
1253
+ this.sessions.delete(id);
1254
+ this.sessionEnvs.delete(id);
1255
+ this.codexWsProxies.delete(id);
1256
+ pruned++;
1257
+ }
1258
+ }
1259
+ return pruned;
1260
+ }
1261
+
1262
+ /**
1263
+ * Kill all sessions.
1264
+ */
1265
+ async killAll(): Promise<void> {
1266
+ const ids = [...this.processes.keys()];
1267
+ await Promise.all(ids.map((id) => this.kill(id)));
1268
+ }
1269
+
1270
+ private async pipeStream(
1271
+ sessionId: string,
1272
+ stream: ReadableStream<Uint8Array> | null,
1273
+ label: "stdout" | "stderr",
1274
+ ): Promise<void> {
1275
+ if (!stream) return;
1276
+ const reader = stream.getReader();
1277
+ const decoder = new TextDecoder();
1278
+ const log = label === "stdout" ? console.log : console.error;
1279
+ try {
1280
+ while (true) {
1281
+ const { done, value } = await reader.read();
1282
+ if (done) break;
1283
+ const text = decoder.decode(value);
1284
+ if (text.trim()) {
1285
+ log(`[session:${sessionId}:${label}] ${text.trimEnd()}`);
1286
+ }
1287
+ }
1288
+ } catch {
1289
+ // stream closed
1290
+ }
1291
+ }
1292
+
1293
+ private pipeOutput(sessionId: string, proc: Subprocess): void {
1294
+ const stdout = proc.stdout;
1295
+ const stderr = proc.stderr;
1296
+ if (stdout && typeof stdout !== "number") {
1297
+ this.pipeStream(sessionId, stdout, "stdout");
1298
+ }
1299
+ if (stderr && typeof stderr !== "number") {
1300
+ this.pipeStream(sessionId, stderr, "stderr");
1301
+ }
1302
+ }
1303
+ }