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.
- package/README.md +40 -0
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
- package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
- package/dist/assets/CronManager-DDbz-yiT.js +1 -0
- package/dist/assets/HelpPage-DMfkzERp.js +1 -0
- package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
- package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
- package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
- package/dist/assets/Playground-Fc5cdc5p.js +109 -0
- package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
- package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
- package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
- package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
- package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
- package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
- package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
- package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
- package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
- package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
- package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
- package/dist/assets/index-C8M_PUmX.css +32 -0
- package/dist/assets/index-CEqZnThB.js +204 -0
- package/dist/assets/sw-register-LSSpj6RU.js +1 -0
- package/dist/assets/time-ago-B6r_l9u1.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon-32-original.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/heyhank-mascot-poster.png +0 -0
- package/dist/heyhank-mascot.mp4 +0 -0
- package/dist/heyhank-mascot.webm +0 -0
- package/dist/icon-192-original.png +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512-original.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +21 -0
- package/dist/logo-192.png +0 -0
- package/dist/logo-512.png +0 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo-original.png +0 -0
- package/dist/logo.png +0 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/push-sw.js +34 -0
- package/dist/sw.js +1 -0
- package/dist/workbox-d2a0910a.js +1 -0
- package/package.json +109 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.ts +357 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-timeout.ts +107 -0
- package/server/agent-types.ts +122 -0
- package/server/ai-validation-settings.ts +37 -0
- package/server/ai-validator.ts +181 -0
- package/server/anthropic-provider-migration.ts +48 -0
- package/server/assistant-store.ts +272 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-approve.ts +153 -0
- package/server/auto-namer.ts +36 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.ts +61 -0
- package/server/calendar-service.ts +434 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.ts +1303 -0
- package/server/codex-adapter.ts +3027 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.ts +27 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.ts +1053 -0
- package/server/cost-tracker.ts +222 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/email-service.ts +354 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +75 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.ts +170 -0
- package/server/federation/node-connection.ts +190 -0
- package/server/federation/node-manager.ts +366 -0
- package/server/federation/node-store.ts +86 -0
- package/server/federation/node-types.ts +121 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.ts +379 -0
- package/server/google-media.ts +342 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +491 -0
- package/server/internal-ai.ts +237 -0
- package/server/kill-switch.ts +99 -0
- package/server/llm-providers.ts +342 -0
- package/server/logger.ts +259 -0
- package/server/mcp-registry.ts +401 -0
- package/server/message-bus.ts +271 -0
- package/server/message-delivery.ts +128 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.ts +13 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/provider-manager.ts +111 -0
- package/server/provider-registry.ts +393 -0
- package/server/push-notifications.ts +221 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.ts +320 -0
- package/server/reminder-scheduler.ts +38 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.ts +264 -0
- package/server/routes/assistant-routes.ts +90 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/federation-routes.ts +76 -0
- package/server/routes/fs-routes.ts +622 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/llm-routes.ts +166 -0
- package/server/routes/media-routes.ts +135 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/platform-routes.ts +1379 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/provider-routes.ts +109 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +285 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/socialmedia-routes.ts +208 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes/telephony-routes.ts +259 -0
- package/server/routes.ts +1379 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.ts +457 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.ts +824 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +511 -0
- package/server/settings-manager.ts +149 -0
- package/server/shared-context.ts +157 -0
- package/server/socialmedia/adapter.ts +15 -0
- package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
- package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
- package/server/socialmedia/manager.ts +227 -0
- package/server/socialmedia/store.ts +98 -0
- package/server/socialmedia/types.ts +89 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/telephony/audio-bridge.ts +331 -0
- package/server/telephony/call-manager.ts +457 -0
- package/server/telephony/call-types.ts +108 -0
- package/server/telephony/telephony-store.ts +119 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.ts +192 -0
- package/server/usage-limits.ts +225 -0
- package/server/web-push.d.ts +51 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +121 -0
- 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
|
+
}
|