the-campfire 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 (180) hide show
  1. package/bin/cli.ts +182 -0
  2. package/dist/apple-touch-icon.png +0 -0
  3. package/dist/assets/MermaidDiagram-By1J3Whj.js +2 -0
  4. package/dist/assets/_basePickBy-Dq6gnD-p.js +1 -0
  5. package/dist/assets/_baseUniq-SpgN6cGc.js +1 -0
  6. package/dist/assets/arc-D9SEA8dX.js +1 -0
  7. package/dist/assets/architectureDiagram-VXUJARFQ-CoTcwLeR.js +36 -0
  8. package/dist/assets/blockDiagram-VD42YOAC-Dvc63voV.js +122 -0
  9. package/dist/assets/c4Diagram-YG6GDRKO-tuDcG4_4.js +10 -0
  10. package/dist/assets/channel-CxhcHn7S.js +1 -0
  11. package/dist/assets/chunk-4BX2VUAB-x1hChYu9.js +1 -0
  12. package/dist/assets/chunk-55IACEB6-BX6HWnWP.js +1 -0
  13. package/dist/assets/chunk-B4BG7PRW-B4IlF03G.js +165 -0
  14. package/dist/assets/chunk-DI55MBZ5-TpYb5rLg.js +220 -0
  15. package/dist/assets/chunk-FMBD7UC4-DZX4LUPx.js +15 -0
  16. package/dist/assets/chunk-QN33PNHL-CuQs0NDY.js +1 -0
  17. package/dist/assets/chunk-QZHKN3VN-CygWwVx8.js +1 -0
  18. package/dist/assets/chunk-TZMSLE5B-CtI-IdKV.js +1 -0
  19. package/dist/assets/classDiagram-2ON5EDUG-BM0LptJF.js +1 -0
  20. package/dist/assets/classDiagram-v2-WZHVMYZB-BM0LptJF.js +1 -0
  21. package/dist/assets/clone-C6kt1rE7.js +1 -0
  22. package/dist/assets/cose-bilkent-S5V4N54A-PdySs991.js +1 -0
  23. package/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
  24. package/dist/assets/dagre-6UL2VRFP-J0SxEiEu.js +4 -0
  25. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  26. package/dist/assets/diagram-PSM6KHXK-CW0HLXaH.js +24 -0
  27. package/dist/assets/diagram-QEK2KX5R-DADHF-k4.js +43 -0
  28. package/dist/assets/diagram-S2PKOQOG-9S0-PC3S.js +24 -0
  29. package/dist/assets/erDiagram-Q2GNP2WA-BEHAiY_q.js +60 -0
  30. package/dist/assets/flowDiagram-NV44I4VS-Cu6n9Xnd.js +162 -0
  31. package/dist/assets/ganttDiagram-JELNMOA3-Cr_BHryu.js +267 -0
  32. package/dist/assets/gitGraphDiagram-V2S2FVAM-D5pT35v5.js +65 -0
  33. package/dist/assets/graph-DKRbrxcs.js +1 -0
  34. package/dist/assets/index-Byjg9zgI.css +32 -0
  35. package/dist/assets/index-Cwj3m0hT.js +284 -0
  36. package/dist/assets/infoDiagram-HS3SLOUP-BaJlrgKV.js +2 -0
  37. package/dist/assets/init-Gi6I4Gst.js +1 -0
  38. package/dist/assets/journeyDiagram-XKPGCS4Q-CXC4-DJv.js +139 -0
  39. package/dist/assets/kanban-definition-3W4ZIXB7-BtHBu9cx.js +89 -0
  40. package/dist/assets/katex-BlHpptmG.js +261 -0
  41. package/dist/assets/layout-DnxSwLDJ.js +1 -0
  42. package/dist/assets/linear-CWbbIT8l.js +1 -0
  43. package/dist/assets/mermaid.core-DXn1cFAK.js +256 -0
  44. package/dist/assets/mindmap-definition-VGOIOE7T-BR8br1hs.js +68 -0
  45. package/dist/assets/ordinal-BENe2yWM.js +1 -0
  46. package/dist/assets/pieDiagram-ADFJNKIX-B7RmXV7I.js +30 -0
  47. package/dist/assets/quadrantDiagram-AYHSOK5B-DK0FhyV_.js +7 -0
  48. package/dist/assets/requirementDiagram-UZGBJVZJ-3zAGxSpL.js +64 -0
  49. package/dist/assets/sankeyDiagram-TZEHDZUN-DZppKtjc.js +10 -0
  50. package/dist/assets/sequenceDiagram-WL72ISMW-D1x3r3wV.js +145 -0
  51. package/dist/assets/stateDiagram-FKZM4ZOC-2ByD08Vq.js +1 -0
  52. package/dist/assets/stateDiagram-v2-4FDKWEC3-Vm1cYjgV.js +1 -0
  53. package/dist/assets/timeline-definition-IT6M3QCI-l5S35biR.js +61 -0
  54. package/dist/assets/treemap-GDKQZRPO-DIPadPiF.js +162 -0
  55. package/dist/assets/xychartDiagram-PRI3JC2R-9GUvwS2b.js +7 -0
  56. package/dist/favicon.svg +3 -0
  57. package/dist/fonts/Geist-Variable.woff2 +0 -0
  58. package/dist/fonts/GeistMono-Variable.woff2 +0 -0
  59. package/dist/icon-192.png +0 -0
  60. package/dist/icon-512.png +0 -0
  61. package/dist/index.html +33 -0
  62. package/dist/logo-codex.svg +14 -0
  63. package/dist/logo.svg +3 -0
  64. package/dist/manifest.json +26 -0
  65. package/dist/og-image.png +0 -0
  66. package/dist/sw.js +130 -0
  67. package/package.json +92 -0
  68. package/server/ADAPTERS.md +121 -0
  69. package/server/adapter-registry-types.ts +28 -0
  70. package/server/adapter-registry.ts +293 -0
  71. package/server/adapter-types.ts +51 -0
  72. package/server/agent-executor.ts +249 -0
  73. package/server/agent-mcp-bridge.ts +148 -0
  74. package/server/agent-mcp-stdio.ts +171 -0
  75. package/server/agent-mcp-tools.ts +76 -0
  76. package/server/agent-store.ts +183 -0
  77. package/server/agent-types.ts +79 -0
  78. package/server/aider-adapter.ts +525 -0
  79. package/server/auth.ts +153 -0
  80. package/server/auto-namer.ts +132 -0
  81. package/server/capability-discovery.ts +431 -0
  82. package/server/claude-container-auth.ts +40 -0
  83. package/server/claude-stdio-adapter.ts +452 -0
  84. package/server/clawhub-export.ts +218 -0
  85. package/server/cli-launcher.ts +1613 -0
  86. package/server/codex-adapter.ts +1987 -0
  87. package/server/codex-container-auth.ts +40 -0
  88. package/server/codex-home.ts +26 -0
  89. package/server/collective-intelligence.ts +401 -0
  90. package/server/commands-discovery.ts +160 -0
  91. package/server/constants.ts +5 -0
  92. package/server/container-manager.ts +328 -0
  93. package/server/cron-scheduler.ts +243 -0
  94. package/server/cron-store.ts +149 -0
  95. package/server/cron-types.ts +63 -0
  96. package/server/deliberation-engine.ts +323 -0
  97. package/server/embedding.ts +89 -0
  98. package/server/env-manager.ts +146 -0
  99. package/server/environment-detector.ts +106 -0
  100. package/server/environment-rules.ts +106 -0
  101. package/server/gallery-store.ts +216 -0
  102. package/server/gallery-types.ts +52 -0
  103. package/server/gallery-votes.ts +101 -0
  104. package/server/git-utils.ts +384 -0
  105. package/server/github-pr.ts +379 -0
  106. package/server/goose-adapter.ts +929 -0
  107. package/server/image-pull-manager.ts +144 -0
  108. package/server/index.ts +363 -0
  109. package/server/linear-project-manager.ts +78 -0
  110. package/server/moltbook-client.ts +160 -0
  111. package/server/openclaw-adapter.ts +916 -0
  112. package/server/openclaw-channel/channel.ts +188 -0
  113. package/server/openclaw-channel/index.ts +68 -0
  114. package/server/openclaw-channel/package.json +19 -0
  115. package/server/openclaw-channel/types.ts +93 -0
  116. package/server/opencode-adapter.ts +951 -0
  117. package/server/openhands-adapter.ts +775 -0
  118. package/server/orchestrator-executor.ts +184 -0
  119. package/server/orchestrator-store.ts +106 -0
  120. package/server/orchestrator-types.ts +86 -0
  121. package/server/path-resolver.ts +168 -0
  122. package/server/pr-poller.ts +162 -0
  123. package/server/proactive-keepalive.ts +121 -0
  124. package/server/prompt-manager.ts +103 -0
  125. package/server/protocol-monitor.ts +197 -0
  126. package/server/race-controller.ts +295 -0
  127. package/server/race-store.ts +82 -0
  128. package/server/recorder.ts +359 -0
  129. package/server/recording-hub/compat-validator.ts +122 -0
  130. package/server/recording-hub/diagnostics.ts +289 -0
  131. package/server/recording-hub/hub-routes.ts +146 -0
  132. package/server/recording-hub/hub-store.ts +224 -0
  133. package/server/replay.ts +78 -0
  134. package/server/routes/adapter-routes.ts +31 -0
  135. package/server/routes/agent-mcp-routes.ts +35 -0
  136. package/server/routes/agent-routes.ts +185 -0
  137. package/server/routes/auth-routes.ts +101 -0
  138. package/server/routes/ci-routes.ts +165 -0
  139. package/server/routes/commands-routes.ts +94 -0
  140. package/server/routes/cron-routes.ts +100 -0
  141. package/server/routes/env-routes.ts +50 -0
  142. package/server/routes/folder-routes.ts +56 -0
  143. package/server/routes/fs-routes.ts +403 -0
  144. package/server/routes/gallery-routes.ts +410 -0
  145. package/server/routes/git-routes.ts +108 -0
  146. package/server/routes/index.ts +62 -0
  147. package/server/routes/linear-routes.ts +254 -0
  148. package/server/routes/monitor-routes.ts +11 -0
  149. package/server/routes/orchestrator-routes.ts +210 -0
  150. package/server/routes/prompt-routes.ts +47 -0
  151. package/server/routes/race-routes.ts +103 -0
  152. package/server/routes/recording-routes.ts +101 -0
  153. package/server/routes/route-deps.ts +32 -0
  154. package/server/routes/session-routes.ts +560 -0
  155. package/server/routes/settings-routes.ts +108 -0
  156. package/server/routes/skills-routes.ts +52 -0
  157. package/server/routes/system-routes.ts +243 -0
  158. package/server/routes/webhook-routes.ts +135 -0
  159. package/server/routes.ts +44 -0
  160. package/server/security-middleware.ts +100 -0
  161. package/server/semantic-memory.ts +389 -0
  162. package/server/service.ts +733 -0
  163. package/server/session-folders.ts +107 -0
  164. package/server/session-git-info.ts +89 -0
  165. package/server/session-linear-issues.ts +94 -0
  166. package/server/session-names.ts +67 -0
  167. package/server/session-store.ts +169 -0
  168. package/server/session-types.ts +415 -0
  169. package/server/settings-manager.ts +120 -0
  170. package/server/shared-context.ts +401 -0
  171. package/server/skills-manager.ts +250 -0
  172. package/server/sub-session-manager.ts +263 -0
  173. package/server/terminal-manager.ts +185 -0
  174. package/server/update-checker.ts +114 -0
  175. package/server/usage-limits.ts +192 -0
  176. package/server/webhook-manager.ts +228 -0
  177. package/server/webhook-store.ts +168 -0
  178. package/server/webhook-types.ts +66 -0
  179. package/server/worktree-tracker.ts +84 -0
  180. package/server/ws-bridge.ts +2322 -0
@@ -0,0 +1,1613 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ mkdirSync,
4
+ existsSync,
5
+ readFileSync,
6
+ writeFileSync,
7
+ copyFileSync,
8
+ cpSync,
9
+ realpathSync,
10
+ } from "node:fs";
11
+ import { join, resolve } from "node:path";
12
+ import type { Subprocess } from "bun";
13
+ import type { SessionStore } from "./session-store.js";
14
+ import type { BackendType, DetectedEnvironment } from "./session-types.js";
15
+ import type { RecorderManager } from "./recorder.js";
16
+ import { CodexAdapter } from "./codex-adapter.js";
17
+ import { GooseAdapter } from "./goose-adapter.js";
18
+ import { AiderAdapter } from "./aider-adapter.js";
19
+ import { OpenHandsAdapter } from "./openhands-adapter.js";
20
+ import { OpenClawAdapter } from "./openclaw-adapter.js";
21
+ import { OpenCodeAdapter } from "./opencode-adapter.js";
22
+ import { ClaudeStdioAdapter } from "./claude-stdio-adapter.js";
23
+ import type { AgentAdapter } from "./adapter-types.js";
24
+ import { resolveBinary, getEnrichedPath } from "./path-resolver.js";
25
+ import {
26
+ getLegacyCodexHome,
27
+ resolveCampfireCodexSessionHome,
28
+ } from "./codex-home.js";
29
+ import { getSettings } from "./settings-manager.js";
30
+ import { detectEnvironment } from "./environment-detector.js";
31
+
32
+ export interface SdkSessionInfo {
33
+ sessionId: string;
34
+ pid?: number;
35
+ state: "starting" | "connected" | "running" | "exited";
36
+ exitCode?: number | null;
37
+ model?: string;
38
+ permissionMode?: string;
39
+ cwd: string;
40
+ createdAt: number;
41
+ /** The CLI's internal session ID (from system.init), used for --resume */
42
+ cliSessionId?: string;
43
+ archived?: boolean;
44
+ /** Whether this session uses a git worktree */
45
+ isWorktree?: boolean;
46
+ /** The original repo root path */
47
+ repoRoot?: string;
48
+ /** Conceptual branch this session is working on (what user selected) */
49
+ branch?: string;
50
+ /** Actual git branch in the worktree (may differ for -wt-N branches) */
51
+ actualBranch?: string;
52
+ /** User-facing session name */
53
+ name?: string;
54
+ /** Which backend this session uses */
55
+ backendType?: BackendType;
56
+ /** Claude transport used for this session. Undefined means legacy persisted data. */
57
+ claudeTransport?: "stdio" | "sdk-url";
58
+ /** Git branch from bridge state (enriched by REST API) */
59
+ gitBranch?: string;
60
+ /** Git ahead count (enriched by REST API) */
61
+ gitAhead?: number;
62
+ /** Git behind count (enriched by REST API) */
63
+ gitBehind?: number;
64
+ /** Total lines added (enriched by REST API) */
65
+ totalLinesAdded?: number;
66
+ /** Total lines removed (enriched by REST API) */
67
+ totalLinesRemoved?: number;
68
+ /** Whether internet/web search is enabled for Codex sessions */
69
+ codexInternetAccess?: boolean;
70
+ /** Sandbox mode selected for Codex sessions */
71
+ codexSandbox?: "workspace-write" | "danger-full-access";
72
+ /** Reasoning effort for Codex o-series models */
73
+ codexReasoningEffort?: "low" | "medium" | "high";
74
+ /** If this session was spawned by a cron job */
75
+ cronJobId?: string;
76
+ /** Human-readable name of the cron job that spawned this session */
77
+ cronJobName?: string;
78
+ /** Environment variables injected at session creation (persisted for relaunch) */
79
+ sessionEnv?: Record<string, string>;
80
+ /** Session that spawned this one, when used as a sub-agent. */
81
+ parentSessionId?: string;
82
+ /** Orchestration role assigned by Campfire. */
83
+ orchestrationRole?: "lead" | "subagent" | "race_entry";
84
+ /** Environment detections computed for the session cwd. */
85
+ detectedEnvironment?: DetectedEnvironment;
86
+ }
87
+
88
+ export interface LaunchOptions {
89
+ model?: string;
90
+ permissionMode?: string;
91
+ cwd?: string;
92
+ claudeBinary?: string;
93
+ codexBinary?: string;
94
+ gooseBinary?: string;
95
+ aiderBinary?: string;
96
+ openhandsBinary?: string;
97
+ openclawBinary?: string;
98
+ opencodeBinary?: string;
99
+ allowedTools?: string[];
100
+ env?: Record<string, string>;
101
+ backendType?: BackendType;
102
+ /** Codex sandbox mode. */
103
+ codexSandbox?: "workspace-write" | "danger-full-access";
104
+ /** Whether Codex internet/web search should be enabled for this session. */
105
+ codexInternetAccess?: boolean;
106
+ /** Reasoning effort for Codex o-series models (low/medium/high). */
107
+ codexReasoningEffort?: "low" | "medium" | "high";
108
+ /** Optional override for CODEX_HOME used by Codex sessions. */
109
+ codexHome?: string;
110
+ /** Docker container ID — when set, CLI is spawned inside the container via docker exec */
111
+ containerId?: string;
112
+ /** Pre-resolved worktree info from the session creation flow */
113
+ worktreeInfo?: {
114
+ isWorktree: boolean;
115
+ repoRoot: string;
116
+ branch: string;
117
+ actualBranch: string;
118
+ worktreePath: string;
119
+ };
120
+ parentSessionId?: string;
121
+ orchestrationRole?: "lead" | "subagent" | "race_entry";
122
+ detectedEnvironment?: DetectedEnvironment;
123
+ }
124
+
125
+ /**
126
+ * Manages CLI backend processes (Claude Code/Codex/Goose/etc. via stdio,
127
+ * with legacy Claude --sdk-url WebSocket available by environment flag).
128
+ */
129
+ function useClaudeSdkUrlTransport(): boolean {
130
+ return process.env.CAMPFIRE_CLAUDE_TRANSPORT === "sdk-url";
131
+ }
132
+
133
+ /** Build Claude Code CLI arguments for the legacy --sdk-url transport. */
134
+ function buildClaudeSdkUrlArgs(sdkUrl: string, options: LaunchOptions & { resumeSessionId?: string }): string[] {
135
+ const args = ["--sdk-url", sdkUrl, "--print", "--output-format", "stream-json", "--input-format", "stream-json", "--verbose"];
136
+ if (options.model) args.push("--model", options.model);
137
+ if (options.permissionMode) {
138
+ args.push("--permission-mode", options.permissionMode);
139
+ if (options.permissionMode === "bypassPermissions") args.push("--dangerously-skip-permissions");
140
+ }
141
+ if (options.allowedTools) {
142
+ for (const tool of options.allowedTools) args.push("--allowedTools", tool);
143
+ }
144
+ return args;
145
+ }
146
+
147
+ /** Build Claude Code CLI arguments for the long-lived stdio transport. */
148
+ function buildClaudeStdioArgs(options: LaunchOptions & { resumeSessionId?: string }): string[] {
149
+ const args = [
150
+ "-p",
151
+ "--input-format", "stream-json",
152
+ "--output-format", "stream-json",
153
+ "--verbose",
154
+ "--permission-prompt-tool", "stdio",
155
+ ];
156
+ if (options.model) args.push("--model", options.model);
157
+ if (options.permissionMode) {
158
+ args.push("--permission-mode", options.permissionMode);
159
+ if (options.permissionMode === "bypassPermissions") args.push("--dangerously-skip-permissions");
160
+ }
161
+ if (options.allowedTools) {
162
+ for (const tool of options.allowedTools) args.push("--allowedTools", tool);
163
+ }
164
+ if (options.resumeSessionId) {
165
+ args.push("--resume", options.resumeSessionId);
166
+ }
167
+ return args;
168
+ }
169
+
170
+ function applyClaudeAuthEnv(env: Record<string, string | undefined>): void {
171
+ const settings = getSettings();
172
+ if (!env.CLAUDE_CODE_OAUTH_TOKEN && settings.claudeOAuthToken.trim()) {
173
+ env.CLAUDE_CODE_OAUTH_TOKEN = settings.claudeOAuthToken.trim();
174
+ }
175
+ if (!env.ANTHROPIC_API_KEY && settings.anthropicApiKey.trim()) {
176
+ env.ANTHROPIC_API_KEY = settings.anthropicApiKey.trim();
177
+ }
178
+ }
179
+
180
+ /** Build docker exec spawn command for running CLI inside a container. */
181
+ function buildContainerSpawn(
182
+ sessionId: string, binary: string, args: string[], sdkUrl: string | null,
183
+ options: LaunchOptions & { containerId: string }, env: Record<string, string | undefined>,
184
+ ): { spawnCmd: string[]; spawnEnv: Record<string, string | undefined>; spawnCwd: string | undefined } {
185
+ const containerSdkUrl = sdkUrl?.replace("localhost", "host.docker.internal") ?? null;
186
+ const containerArgs = containerSdkUrl ? args.map((a) => a === sdkUrl ? containerSdkUrl : a) : args;
187
+ const dockerArgs = ["docker", "exec", "-i"];
188
+ if (options.env) {
189
+ for (const [k, v] of Object.entries(options.env)) {
190
+ dockerArgs.push("-e", `${k}=${v}`);
191
+ }
192
+ }
193
+ const innerCmd = [binary, ...containerArgs].map((a) => a.includes(" ") ? `'${a}'` : a).join(" ");
194
+ const installSteps = [
195
+ `command -v claude >/dev/null 2>&1 || {`,
196
+ ` command -v npm >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq nodejs npm >/dev/null 2>&1 || true; };`,
197
+ ` npm install -g @anthropic-ai/claude-code@latest 2>/dev/null || true;`,
198
+ `}`,
199
+ ].join(" ");
200
+ dockerArgs.push(
201
+ "-e", `PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/bin`,
202
+ "-w", "/workspace",
203
+ options.containerId,
204
+ "bash", "-lc", `${installSteps}; ${innerCmd}`,
205
+ );
206
+ console.log(`[cli-launcher] Spawning session ${sessionId} INSIDE container ${options.containerId}`);
207
+ return { spawnCmd: dockerArgs, spawnEnv: { ...process.env, PATH: getEnrichedPath() }, spawnCwd: undefined };
208
+ }
209
+
210
+ export class CliLauncher {
211
+ private readonly sessions = new Map<string, SdkSessionInfo>();
212
+ private readonly processes = new Map<string, Subprocess>();
213
+ private readonly port: number;
214
+ private store: SessionStore | null = null;
215
+ private recorder: RecorderManager | null = null;
216
+ private onAdapter: ((sessionId: string, adapter: AgentAdapter, backendType: BackendType) => void) | null = null;
217
+ private onExited: ((sessionId: string, exitCode: number | null) => void) | null = null;
218
+
219
+ constructor(port: number) {
220
+ this.port = port;
221
+ }
222
+
223
+ /** Register a callback for when an adapter is created (WsBridge needs to attach it). */
224
+ onAdapterCreated(cb: (sessionId: string, adapter: AgentAdapter, backendType: BackendType) => void): void {
225
+ this.onAdapter = cb;
226
+ }
227
+
228
+ /** Register a callback for when a CLI process exits (used by proactive keepalive). */
229
+ onSessionExited(cb: (sessionId: string, exitCode: number | null) => void): void {
230
+ this.onExited = cb;
231
+ }
232
+
233
+ /** Attach a persistent store for surviving server restarts. */
234
+ setStore(store: SessionStore): void {
235
+ this.store = store;
236
+ }
237
+
238
+ /** Attach a recorder for raw message capture. */
239
+ setRecorder(recorder: RecorderManager): void {
240
+ this.recorder = recorder;
241
+ }
242
+
243
+ /** Persist launcher state to disk. */
244
+ private persistState(): void {
245
+ if (!this.store) return;
246
+ const data = Array.from(this.sessions.values());
247
+ this.store.saveLauncher(data);
248
+ }
249
+
250
+ /**
251
+ * Restore sessions from disk and check which PIDs are still alive.
252
+ * Returns the number of recovered sessions.
253
+ */
254
+ restoreFromDisk(): number {
255
+ if (!this.store) return 0;
256
+ const data = this.store.loadLauncher<SdkSessionInfo[]>();
257
+ if (!data || !Array.isArray(data)) return 0;
258
+
259
+ let recovered = 0;
260
+ let changed = false;
261
+ for (const info of data) {
262
+ if (this.sessions.has(info.sessionId)) continue;
263
+
264
+ // Check if the process is still alive
265
+ if (info.pid && info.state !== "exited") {
266
+ try {
267
+ process.kill(info.pid, 0); // signal 0 = just check if alive
268
+ if (info.backendType === "claude" && info.claudeTransport === "stdio") {
269
+ try { process.kill(info.pid, "SIGTERM"); } catch {}
270
+ info.state = "exited";
271
+ info.exitCode = -1;
272
+ info.pid = undefined;
273
+ changed = true;
274
+ } else {
275
+ info.state = "starting"; // WS not yet re-established, wait for CLI to reconnect
276
+ recovered++;
277
+ changed = true;
278
+ }
279
+ this.sessions.set(info.sessionId, info);
280
+ } catch {
281
+ // Process is dead
282
+ info.state = "exited";
283
+ info.exitCode = -1;
284
+ info.pid = undefined;
285
+ changed = true;
286
+ this.sessions.set(info.sessionId, info);
287
+ }
288
+ } else {
289
+ // Already exited or no PID
290
+ this.sessions.set(info.sessionId, info);
291
+ }
292
+ }
293
+ if (recovered > 0) {
294
+ console.log(`[cli-launcher] Recovered ${recovered} live session(s) from disk`);
295
+ }
296
+
297
+ // Also recover sessions from individual session store files that are
298
+ // missing from launcher.json (e.g. after a crash before launcher.json
299
+ // was written). This prevents data loss on VM crash or power loss.
300
+ const storeRecovered = this.recoverFromStoreFiles();
301
+ if (changed || storeRecovered > 0) this.persistState();
302
+
303
+ return recovered + storeRecovered;
304
+ }
305
+
306
+ /** Scan individual session store files for sessions missing from launcher.json. */
307
+ private recoverFromStoreFiles(): number {
308
+ if (!this.store) return 0;
309
+ const storeFiles = this.store.loadAll();
310
+ let count = 0;
311
+ for (const persisted of storeFiles) {
312
+ if (this.sessions.has(persisted.id)) continue;
313
+ const info: SdkSessionInfo = {
314
+ sessionId: persisted.id,
315
+ state: "exited",
316
+ exitCode: -1,
317
+ model: persisted.state?.model,
318
+ permissionMode: persisted.state?.permissionMode,
319
+ cwd: persisted.state?.cwd || "",
320
+ createdAt: 0,
321
+ archived: persisted.archived,
322
+ backendType: persisted.state?.backend_type,
323
+ };
324
+ this.sessions.set(persisted.id, info);
325
+ count++;
326
+ }
327
+ if (count > 0) {
328
+ console.log(`[cli-launcher] Recovered ${count} additional session(s) from session store files`);
329
+ this.persistState();
330
+ }
331
+ return count;
332
+ }
333
+
334
+ /**
335
+ * Launch a new CLI session (Claude Code or Codex).
336
+ */
337
+ launch(options: LaunchOptions = {}): SdkSessionInfo {
338
+ const sessionId = randomUUID();
339
+ const cwd = options.cwd || process.cwd();
340
+ const backendType = options.backendType || "claude";
341
+ const launchOptions = backendType === "codex" && !options.model?.trim()
342
+ ? { ...options, model: undefined }
343
+ : options;
344
+ const detectedEnvironment = launchOptions.detectedEnvironment ?? detectEnvironment(cwd);
345
+
346
+ const info: SdkSessionInfo = {
347
+ sessionId,
348
+ state: "starting",
349
+ model: launchOptions.model,
350
+ permissionMode: launchOptions.permissionMode,
351
+ cwd,
352
+ createdAt: Date.now(),
353
+ backendType,
354
+ parentSessionId: launchOptions.parentSessionId,
355
+ orchestrationRole: launchOptions.orchestrationRole,
356
+ detectedEnvironment,
357
+ claudeTransport: backendType === "claude"
358
+ ? (useClaudeSdkUrlTransport() ? "sdk-url" : "stdio")
359
+ : undefined,
360
+ };
361
+
362
+ if (backendType === "codex") {
363
+ info.codexInternetAccess = launchOptions.codexInternetAccess === true;
364
+ info.codexSandbox = launchOptions.codexSandbox;
365
+ info.codexReasoningEffort = launchOptions.codexReasoningEffort;
366
+ }
367
+
368
+ // Persist env vars so they survive server restarts and relaunches
369
+ if (launchOptions.env && Object.keys(launchOptions.env).length > 0) {
370
+ info.sessionEnv = launchOptions.env;
371
+ }
372
+
373
+ // Store worktree metadata if provided
374
+ if (launchOptions.worktreeInfo) {
375
+ info.isWorktree = launchOptions.worktreeInfo.isWorktree;
376
+ info.repoRoot = launchOptions.worktreeInfo.repoRoot;
377
+ info.branch = launchOptions.worktreeInfo.branch;
378
+ info.actualBranch = launchOptions.worktreeInfo.actualBranch;
379
+ }
380
+
381
+ this.sessions.set(sessionId, info);
382
+
383
+ if (backendType === "codex") {
384
+ this.spawnCodex(sessionId, info, launchOptions);
385
+ } else if (backendType === "goose") {
386
+ this.spawnGoose(sessionId, info, launchOptions);
387
+ } else if (backendType === "aider") {
388
+ this.spawnAider(sessionId, info, launchOptions);
389
+ } else if (backendType === "openhands") {
390
+ this.spawnOpenHands(sessionId, info, launchOptions);
391
+ } else if (backendType === "openclaw") {
392
+ this.spawnOpenClaw(sessionId, info, launchOptions);
393
+ } else if (backendType === "opencode") {
394
+ this.spawnOpenCode(sessionId, info, launchOptions);
395
+ } else {
396
+ this.spawnCLI(sessionId, info, launchOptions);
397
+ }
398
+ return info;
399
+ }
400
+
401
+ /**
402
+ * Relaunch a CLI process for an existing session.
403
+ * Kills the old process if still alive, then spawns a fresh CLI
404
+ * that connects back to the same session in the WsBridge.
405
+ */
406
+ async relaunch(sessionId: string): Promise<boolean> {
407
+ const info = this.sessions.get(sessionId);
408
+ if (!info) return false;
409
+
410
+ // Kill old process if still alive
411
+ const oldProc = this.processes.get(sessionId);
412
+ if (oldProc) {
413
+ try {
414
+ oldProc.kill("SIGTERM");
415
+ await Promise.race([
416
+ oldProc.exited,
417
+ new Promise((r) => setTimeout(r, 2000)),
418
+ ]);
419
+ } catch {}
420
+ this.processes.delete(sessionId);
421
+ } else if (info.pid) {
422
+ // Process from a previous server instance — kill by PID
423
+ try { process.kill(info.pid, "SIGTERM"); } catch {}
424
+ }
425
+
426
+ info.state = "starting";
427
+
428
+ // Persisted env vars are re-injected on every relaunch
429
+ const relaunchEnv = info.sessionEnv;
430
+
431
+ if (info.backendType === "codex") {
432
+ this.spawnCodex(sessionId, info, {
433
+ model: info.model,
434
+ permissionMode: info.permissionMode,
435
+ cwd: info.cwd,
436
+ codexSandbox: info.codexSandbox,
437
+ codexInternetAccess: info.codexInternetAccess,
438
+ codexReasoningEffort: info.codexReasoningEffort,
439
+ env: relaunchEnv,
440
+ detectedEnvironment: info.detectedEnvironment,
441
+ });
442
+ } else if (info.backendType === "goose") {
443
+ this.spawnGoose(sessionId, info, {
444
+ model: info.model,
445
+ permissionMode: info.permissionMode,
446
+ cwd: info.cwd,
447
+ env: relaunchEnv,
448
+ detectedEnvironment: info.detectedEnvironment,
449
+ });
450
+ } else if (info.backendType === "aider") {
451
+ this.spawnAider(sessionId, info, {
452
+ model: info.model,
453
+ permissionMode: info.permissionMode,
454
+ cwd: info.cwd,
455
+ env: relaunchEnv,
456
+ detectedEnvironment: info.detectedEnvironment,
457
+ });
458
+ } else if (info.backendType === "openhands") {
459
+ this.spawnOpenHands(sessionId, info, {
460
+ model: info.model,
461
+ permissionMode: info.permissionMode,
462
+ cwd: info.cwd,
463
+ env: relaunchEnv,
464
+ detectedEnvironment: info.detectedEnvironment,
465
+ });
466
+ } else if (info.backendType === "openclaw") {
467
+ this.spawnOpenClaw(sessionId, info, {
468
+ model: info.model,
469
+ permissionMode: info.permissionMode,
470
+ cwd: info.cwd,
471
+ env: relaunchEnv,
472
+ detectedEnvironment: info.detectedEnvironment,
473
+ });
474
+ } else if (info.backendType === "opencode") {
475
+ this.spawnOpenCode(sessionId, info, {
476
+ model: info.model,
477
+ permissionMode: info.permissionMode,
478
+ cwd: info.cwd,
479
+ env: relaunchEnv,
480
+ detectedEnvironment: info.detectedEnvironment,
481
+ });
482
+ } else {
483
+ this.spawnCLI(sessionId, info, {
484
+ model: info.model,
485
+ permissionMode: info.permissionMode,
486
+ cwd: info.cwd,
487
+ resumeSessionId: info.cliSessionId,
488
+ env: relaunchEnv,
489
+ detectedEnvironment: info.detectedEnvironment,
490
+ });
491
+ }
492
+ return true;
493
+ }
494
+
495
+ /**
496
+ * Get all sessions in "starting" state (awaiting CLI WebSocket connection).
497
+ */
498
+ getStartingSessions(): SdkSessionInfo[] {
499
+ return Array.from(this.sessions.values()).filter((s) => s.state === "starting");
500
+ }
501
+
502
+ private prepareClaudeLaunch(sessionId: string, info: SdkSessionInfo, options: LaunchOptions & { resumeSessionId?: string }): string | null {
503
+ let binary = options.claudeBinary || "claude";
504
+ const resolved = resolveBinary(binary);
505
+ if (resolved) {
506
+ binary = resolved;
507
+ } else {
508
+ console.error(`[cli-launcher] Binary "${binary}" not found in PATH`);
509
+ info.state = "exited";
510
+ info.exitCode = 127;
511
+ this.persistState();
512
+ return null;
513
+ }
514
+
515
+ // Inject CLAUDE.md guardrails for worktree sessions
516
+ if (info.isWorktree && info.branch) {
517
+ this.injectWorktreeGuardrails(
518
+ info.cwd,
519
+ info.actualBranch || info.branch,
520
+ info.repoRoot || "",
521
+ info.actualBranch && info.actualBranch !== info.branch ? info.branch : undefined,
522
+ );
523
+ }
524
+
525
+ return binary;
526
+ }
527
+
528
+ private spawnCLI(sessionId: string, info: SdkSessionInfo, options: LaunchOptions & { resumeSessionId?: string }): void {
529
+ const binary = this.prepareClaudeLaunch(sessionId, info, options);
530
+ if (!binary) return;
531
+
532
+ if (useClaudeSdkUrlTransport()) {
533
+ this.spawnClaudeSdkUrl(sessionId, info, options, binary);
534
+ } else {
535
+ this.spawnClaudeStdio(sessionId, info, options, binary);
536
+ }
537
+ }
538
+
539
+ private spawnClaudeSdkUrl(
540
+ sessionId: string,
541
+ info: SdkSessionInfo,
542
+ options: LaunchOptions & { resumeSessionId?: string },
543
+ binary: string,
544
+ ): void {
545
+ info.claudeTransport = "sdk-url";
546
+ const sdkUrl = `ws://localhost:${this.port}/ws/cli/${sessionId}`;
547
+ const args = buildClaudeSdkUrlArgs(sdkUrl, options);
548
+
549
+ // Always pass -p "" for headless mode. When relaunching, also pass --resume
550
+ // to restore the CLI's conversation context.
551
+ if (options.resumeSessionId) {
552
+ args.push("--resume", options.resumeSessionId);
553
+ }
554
+ args.push("-p", "");
555
+
556
+ // Use enriched PATH so spawned CLI processes inherit the user's
557
+ // full PATH (nvm, volta, etc.) regardless of how the server started.
558
+ const env: Record<string, string | undefined> = {
559
+ ...process.env,
560
+ CLAUDECODE: undefined,
561
+ ...options.env,
562
+ PATH: getEnrichedPath(),
563
+ };
564
+ applyClaudeAuthEnv(env);
565
+
566
+ const { spawnCmd, spawnEnv, spawnCwd } = options.containerId
567
+ ? buildContainerSpawn(sessionId, binary, args, sdkUrl, options as LaunchOptions & { containerId: string }, env)
568
+ : { spawnCmd: [binary, ...args], spawnEnv: env, spawnCwd: info.cwd };
569
+
570
+ if (!options.containerId) {
571
+ console.log(`[cli-launcher] Spawning session ${sessionId}: ${binary} ${args.join(" ")}`);
572
+ }
573
+
574
+ let proc: Subprocess;
575
+ try {
576
+ proc = Bun.spawn(spawnCmd, {
577
+ cwd: spawnCwd,
578
+ env: spawnEnv,
579
+ stdout: "pipe",
580
+ stderr: "pipe",
581
+ });
582
+ } catch (error) {
583
+ console.error(`[cli-launcher] Failed to spawn Claude session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
584
+ info.state = "exited";
585
+ info.exitCode = 127;
586
+ this.processes.delete(sessionId);
587
+ this.persistState();
588
+ return;
589
+ }
590
+
591
+ info.pid = proc.pid;
592
+ this.processes.set(sessionId, proc);
593
+
594
+ // Stream stdout/stderr for debugging
595
+ this.pipeOutput(sessionId, proc);
596
+
597
+ // Monitor process exit
598
+ const spawnedAt = Date.now();
599
+ proc.exited.then((exitCode) => {
600
+ console.log(`[cli-launcher] Session ${sessionId} exited (code=${exitCode})`);
601
+ const session = this.sessions.get(sessionId);
602
+ if (session) {
603
+ session.state = "exited";
604
+ session.exitCode = exitCode;
605
+
606
+ // If the process exited almost immediately with --resume, the resume likely failed.
607
+ // Clear cliSessionId so the next relaunch starts fresh.
608
+ const uptime = Date.now() - spawnedAt;
609
+ if (uptime < 5000 && options.resumeSessionId) {
610
+ console.error(`[cli-launcher] Session ${sessionId} exited immediately after --resume (${uptime}ms). Clearing cliSessionId for fresh start.`);
611
+ session.cliSessionId = undefined;
612
+ }
613
+ }
614
+ // Notify proactive keepalive
615
+ this.onExited?.(sessionId, exitCode);
616
+ this.processes.delete(sessionId);
617
+ this.persistState();
618
+ });
619
+
620
+ this.persistState();
621
+ }
622
+
623
+ private spawnClaudeStdio(
624
+ sessionId: string,
625
+ info: SdkSessionInfo,
626
+ options: LaunchOptions & { resumeSessionId?: string },
627
+ binary: string,
628
+ ): void {
629
+ info.claudeTransport = "stdio";
630
+ const args = buildClaudeStdioArgs(options);
631
+
632
+ const env: Record<string, string | undefined> = {
633
+ ...process.env,
634
+ CLAUDECODE: undefined,
635
+ ...options.env,
636
+ PATH: getEnrichedPath(),
637
+ };
638
+ applyClaudeAuthEnv(env);
639
+
640
+ const { spawnCmd, spawnEnv, spawnCwd } = options.containerId
641
+ ? buildContainerSpawn(sessionId, binary, args, null, options as LaunchOptions & { containerId: string }, env)
642
+ : { spawnCmd: [binary, ...args], spawnEnv: env, spawnCwd: info.cwd };
643
+
644
+ if (!options.containerId) {
645
+ console.log(`[cli-launcher] Spawning Claude stdio session ${sessionId}: ${binary} ${args.join(" ")}`);
646
+ }
647
+
648
+ let proc: Subprocess;
649
+ try {
650
+ proc = Bun.spawn(spawnCmd, {
651
+ cwd: spawnCwd,
652
+ env: spawnEnv,
653
+ stdin: "pipe",
654
+ stdout: "pipe",
655
+ stderr: "pipe",
656
+ });
657
+ } catch (error) {
658
+ console.error(`[cli-launcher] Failed to spawn Claude stdio session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
659
+ info.state = "exited";
660
+ info.exitCode = 127;
661
+ info.cliSessionId = undefined;
662
+ this.processes.delete(sessionId);
663
+ this.persistState();
664
+ return;
665
+ }
666
+
667
+ info.pid = proc.pid;
668
+ this.processes.set(sessionId, proc);
669
+
670
+ const stderr = proc.stderr;
671
+ if (stderr && typeof stderr !== "number") {
672
+ this.pipeStream(sessionId, stderr, "stderr");
673
+ }
674
+
675
+ const adapter = new ClaudeStdioAdapter(proc, sessionId, {
676
+ model: options.model,
677
+ cwd: info.cwd,
678
+ permissionMode: options.permissionMode,
679
+ recorder: this.recorder ?? undefined,
680
+ });
681
+
682
+ adapter.onInitError((error) => {
683
+ console.error(`[cli-launcher] Claude stdio session ${sessionId} init failed: ${error}`);
684
+ const session = this.sessions.get(sessionId);
685
+ if (session) {
686
+ session.state = "exited";
687
+ session.exitCode = 1;
688
+ session.cliSessionId = undefined;
689
+ }
690
+ this.persistState();
691
+ });
692
+
693
+ if (this.onAdapter) {
694
+ this.onAdapter(sessionId, adapter, "claude");
695
+ }
696
+
697
+ info.state = "connected";
698
+
699
+ const spawnedAt = Date.now();
700
+ proc.exited.then((exitCode) => {
701
+ console.log(`[cli-launcher] Claude stdio session ${sessionId} exited (code=${exitCode})`);
702
+ const session = this.sessions.get(sessionId);
703
+ if (session) {
704
+ session.state = "exited";
705
+ session.exitCode = exitCode;
706
+
707
+ const uptime = Date.now() - spawnedAt;
708
+ if (uptime < 5000 && options.resumeSessionId) {
709
+ console.error(`[cli-launcher] Claude stdio session ${sessionId} exited immediately after --resume (${uptime}ms). Clearing cliSessionId for fresh start.`);
710
+ session.cliSessionId = undefined;
711
+ }
712
+ }
713
+ this.onExited?.(sessionId, exitCode);
714
+ this.processes.delete(sessionId);
715
+ this.persistState();
716
+ });
717
+
718
+ this.persistState();
719
+ }
720
+
721
+ /**
722
+ * Spawn a Codex app-server subprocess for a session.
723
+ * Unlike Claude Code (which connects back via WebSocket), Codex uses stdio.
724
+ */
725
+ private prepareCodexHome(codexHome: string): void {
726
+ mkdirSync(codexHome, { recursive: true });
727
+
728
+ const legacyHome = getLegacyCodexHome();
729
+ if (resolve(legacyHome) === resolve(codexHome) || !existsSync(legacyHome)) {
730
+ return;
731
+ }
732
+
733
+ // Bootstrap only the user-level artifacts Codex needs (auth/config/skills),
734
+ // while intentionally skipping sessions/sqlite to avoid stale rollout indexes.
735
+ const fileSeeds = ["auth.json", "config.toml", "models_cache.json", "version.json"];
736
+ for (const name of fileSeeds) {
737
+ try {
738
+ const src = join(legacyHome, name);
739
+ const dest = join(codexHome, name);
740
+ if (!existsSync(dest) && existsSync(src)) {
741
+ copyFileSync(src, dest);
742
+ }
743
+ } catch (e) {
744
+ console.warn(`[cli-launcher] Failed to bootstrap ${name} from legacy home:`, e);
745
+ }
746
+ }
747
+
748
+ const dirSeeds = ["skills", "vendor_imports", "prompts", "rules"];
749
+ for (const name of dirSeeds) {
750
+ try {
751
+ const src = join(legacyHome, name);
752
+ const dest = join(codexHome, name);
753
+ if (!existsSync(dest) && existsSync(src)) {
754
+ cpSync(src, dest, { recursive: true });
755
+ }
756
+ } catch (e) {
757
+ console.warn(`[cli-launcher] Failed to bootstrap ${name}/ from legacy home:`, e);
758
+ }
759
+ }
760
+ }
761
+
762
+ private async spawnCodex(sessionId: string, info: SdkSessionInfo, options: LaunchOptions): Promise<void> {
763
+ const model = options.model?.trim() ? options.model : undefined;
764
+ let binary = options.codexBinary || "codex";
765
+ const resolved = resolveBinary(binary);
766
+ if (resolved) {
767
+ binary = resolved;
768
+ } else {
769
+ console.error(`[cli-launcher] Binary "${binary}" not found in PATH`);
770
+ info.state = "exited";
771
+ info.exitCode = 127;
772
+ this.persistState();
773
+ return;
774
+ }
775
+
776
+ const args: string[] = ["app-server"];
777
+ const internetEnabled = options.codexInternetAccess === true;
778
+ args.push("-c", `tools.webSearch=${internetEnabled ? "true" : "false"}`);
779
+ const codexHome = resolveCampfireCodexSessionHome(
780
+ sessionId,
781
+ options.codexHome,
782
+ );
783
+ this.prepareCodexHome(codexHome);
784
+
785
+ // The codex binary is a Node.js script with `#!/usr/bin/env node` shebang.
786
+ // When Bun.spawn executes it, the kernel resolves `node` via /usr/bin/env
787
+ // which may find the system Node (e.g. v12) instead of the nvm-managed one.
788
+ // To guarantee the correct Node version, we resolve the `node` binary that
789
+ // lives alongside `codex` and spawn `node <codex.js>` directly.
790
+ const binaryDir = resolve(binary, "..");
791
+ const siblingNode = join(binaryDir, "node");
792
+ const enrichedPath = getEnrichedPath();
793
+ const spawnPath = [binaryDir, ...enrichedPath.split(":")].filter(Boolean).join(":");
794
+
795
+ // Determine whether to invoke node explicitly or use the binary directly.
796
+ // If a `node` binary exists next to `codex`, use it to bypass shebang issues.
797
+ let spawnCmd: string[];
798
+ // Resolve the real path of the codex script (follows symlinks)
799
+ let codexScript: string;
800
+ try {
801
+ codexScript = realpathSync(binary);
802
+ } catch {
803
+ codexScript = binary;
804
+ }
805
+
806
+ // Find a Node.js binary to run codex — Bun.spawn doesn't reliably handle
807
+ // Node.js shebang scripts with stdio pipes. Check sibling first, then PATH.
808
+ let nodeForCodex = siblingNode;
809
+ if (!existsSync(nodeForCodex)) {
810
+ const systemNode = resolveBinary("node");
811
+ nodeForCodex = systemNode || "";
812
+ }
813
+
814
+ if (nodeForCodex && existsSync(nodeForCodex)) {
815
+ spawnCmd = [nodeForCodex, codexScript, ...args];
816
+ } else {
817
+ spawnCmd = [binary, ...args];
818
+ }
819
+
820
+ const env: Record<string, string | undefined> = {
821
+ ...process.env,
822
+ CLAUDECODE: undefined,
823
+ ...options.env,
824
+ CODEX_HOME: codexHome,
825
+ PATH: spawnPath,
826
+ };
827
+
828
+ console.log(`[cli-launcher] Spawning Codex session ${sessionId}: ${spawnCmd.join(" ")}`);
829
+
830
+ // Use Node's child_process.spawn instead of Bun.spawn for Codex.
831
+ // Bun.spawn has compatibility issues with stdio pipes to Node.js
832
+ // child processes — the transport closes prematurely.
833
+ const { spawn: nodeSpawn } = await import("node:child_process");
834
+ const cleanEnv: Record<string, string> = {};
835
+ for (const [k, v] of Object.entries(env)) {
836
+ if (v !== undefined) cleanEnv[k] = v;
837
+ }
838
+ const nodeProc = nodeSpawn(spawnCmd[0], spawnCmd.slice(1), {
839
+ cwd: info.cwd,
840
+ env: cleanEnv,
841
+ stdio: ["pipe", "pipe", "pipe"],
842
+ });
843
+ let processExited = false;
844
+ const markProcessExited = (exitCode: number): void => {
845
+ if (processExited) return;
846
+ processExited = true;
847
+ const session = this.sessions.get(sessionId);
848
+ if (session) {
849
+ session.state = "exited";
850
+ session.exitCode = exitCode;
851
+ }
852
+ this.processes.delete(sessionId);
853
+ this.persistState();
854
+ };
855
+ const procExited = new Promise<number>((resolve) => {
856
+ nodeProc.once("error", (err) => {
857
+ console.error(`[cli-launcher] Codex session ${sessionId} failed to spawn: ${err.message}`);
858
+ markProcessExited(127);
859
+ resolve(127);
860
+ });
861
+ nodeProc.once("exit", (code) => {
862
+ const exitCode = code ?? 1;
863
+ markProcessExited(exitCode);
864
+ resolve(exitCode);
865
+ });
866
+ });
867
+
868
+ // Wrap Node child_process into a Bun-compatible Subprocess interface
869
+ const proc = {
870
+ pid: nodeProc.pid ?? 0,
871
+ stdin: new WritableStream({
872
+ write(chunk: Uint8Array) {
873
+ return new Promise<void>((resolve, reject) => {
874
+ nodeProc.stdin!.write(chunk, (err) => err ? reject(err) : resolve());
875
+ });
876
+ },
877
+ }),
878
+ stdout: new ReadableStream({
879
+ start(controller) {
880
+ nodeProc.stdout!.on("data", (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
881
+ nodeProc.stdout!.on("end", () => controller.close());
882
+ nodeProc.stdout!.on("error", (err) => controller.error(err));
883
+ },
884
+ }),
885
+ stderr: new ReadableStream({
886
+ start(controller) {
887
+ nodeProc.stderr!.on("data", (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
888
+ nodeProc.stderr!.on("end", () => controller.close());
889
+ nodeProc.stderr!.on("error", (err) => controller.error(err));
890
+ },
891
+ }),
892
+ exited: procExited,
893
+ kill(signal?: string) { nodeProc.kill(signal as NodeJS.Signals); },
894
+ ref() { nodeProc.ref(); },
895
+ unref() { nodeProc.unref(); },
896
+ } as unknown as Subprocess;
897
+
898
+ info.pid = proc.pid;
899
+ this.processes.set(sessionId, proc);
900
+
901
+ // Pipe stderr for debugging (stdout is used for JSON-RPC)
902
+ const stderr = proc.stderr;
903
+ if (stderr && typeof stderr !== "number") {
904
+ this.pipeStream(sessionId, stderr, "stderr");
905
+ }
906
+
907
+ // Create the CodexAdapter which handles JSON-RPC and message translation
908
+ // Pass the raw permission mode — the adapter maps it to Codex's approval policy
909
+ const adapter = new CodexAdapter(proc, sessionId, {
910
+ model,
911
+ cwd: info.cwd,
912
+ approvalMode: options.permissionMode,
913
+ threadId: info.cliSessionId,
914
+ sandbox: options.codexSandbox,
915
+ recorder: this.recorder ?? undefined,
916
+ reasoningEffort: options.codexReasoningEffort,
917
+ });
918
+
919
+ // Handle init errors — mark session as exited so UI shows failure.
920
+ // Also clear cliSessionId so the next relaunch starts a fresh thread
921
+ // instead of trying to resume one whose rollout may be missing.
922
+ adapter.onInitError((error) => {
923
+ console.error(`[cli-launcher] Codex session ${sessionId} init failed: ${error}`);
924
+ const session = this.sessions.get(sessionId);
925
+ if (session) {
926
+ session.state = "exited";
927
+ session.exitCode = 1;
928
+ session.cliSessionId = undefined;
929
+ }
930
+ this.persistState();
931
+ });
932
+
933
+ // Notify the WsBridge to attach this adapter
934
+ if (this.onAdapter) {
935
+ this.onAdapter(sessionId, adapter, "codex");
936
+ }
937
+
938
+ // Mark as connected immediately (no WS handshake needed for stdio)
939
+ info.state = "connected";
940
+
941
+ // Monitor process exit
942
+ proc.exited.then((exitCode) => {
943
+ console.log(`[cli-launcher] Codex session ${sessionId} exited (code=${exitCode})`);
944
+ });
945
+
946
+ this.persistState();
947
+ }
948
+
949
+ /**
950
+ * Spawn a Goose ACP subprocess for a session.
951
+ * Uses stdio JSON-RPC — same pattern as Codex.
952
+ */
953
+ private spawnGoose(sessionId: string, info: SdkSessionInfo, options: LaunchOptions): void {
954
+ let binary = options.gooseBinary || "goose";
955
+ const resolved = resolveBinary(binary);
956
+ if (resolved) {
957
+ binary = resolved;
958
+ } else {
959
+ console.error(`[cli-launcher] Binary "${binary}" not found in PATH`);
960
+ info.state = "exited";
961
+ info.exitCode = 127;
962
+ this.persistState();
963
+ return;
964
+ }
965
+
966
+ // Spawn: goose acp --with-builtin developer --with-builtin memory
967
+ const args: string[] = [
968
+ "acp",
969
+ "--with-builtin", "developer",
970
+ "--with-builtin", "memory",
971
+ ];
972
+
973
+ const enrichedPath = getEnrichedPath();
974
+ const env: Record<string, string | undefined> = {
975
+ ...process.env,
976
+ CLAUDECODE: undefined,
977
+ ...options.env,
978
+ PATH: enrichedPath,
979
+ };
980
+
981
+ // Set Goose provider/model via environment if specified
982
+ if (options.model) {
983
+ env.GOOSE_MODEL = options.model;
984
+ }
985
+
986
+ console.log(`[cli-launcher] Spawning Goose session ${sessionId}: ${binary} ${args.join(" ")}`);
987
+
988
+ const proc = Bun.spawn([binary, ...args], {
989
+ cwd: info.cwd,
990
+ env,
991
+ stdin: "pipe",
992
+ stdout: "pipe",
993
+ stderr: "pipe",
994
+ });
995
+
996
+ info.pid = proc.pid;
997
+ this.processes.set(sessionId, proc);
998
+
999
+ // Pipe stderr for debugging (stdout is used for JSON-RPC)
1000
+ const stderr = proc.stderr;
1001
+ if (stderr && typeof stderr !== "number") {
1002
+ this.pipeStream(sessionId, stderr, "stderr");
1003
+ }
1004
+
1005
+ // Create the GooseAdapter which handles JSON-RPC and message translation
1006
+ const adapter = new GooseAdapter(proc, sessionId, {
1007
+ model: options.model,
1008
+ cwd: info.cwd,
1009
+ permissionMode: options.permissionMode,
1010
+ gooseSessionId: info.cliSessionId,
1011
+ recorder: this.recorder ?? undefined,
1012
+ });
1013
+
1014
+ // Handle init errors
1015
+ adapter.onInitError((error) => {
1016
+ console.error(`[cli-launcher] Goose session ${sessionId} init failed: ${error}`);
1017
+ const session = this.sessions.get(sessionId);
1018
+ if (session) {
1019
+ session.state = "exited";
1020
+ session.exitCode = 1;
1021
+ session.cliSessionId = undefined;
1022
+ }
1023
+ this.persistState();
1024
+ });
1025
+
1026
+ // Notify the WsBridge to attach this adapter
1027
+ if (this.onAdapter) {
1028
+ this.onAdapter(sessionId, adapter, "goose");
1029
+ }
1030
+
1031
+ // Mark as connected immediately (no WS handshake needed for stdio)
1032
+ info.state = "connected";
1033
+
1034
+ // Monitor process exit
1035
+ proc.exited.then((exitCode) => {
1036
+ console.log(`[cli-launcher] Goose session ${sessionId} exited (code=${exitCode})`);
1037
+ const session = this.sessions.get(sessionId);
1038
+ if (session) {
1039
+ session.state = "exited";
1040
+ session.exitCode = exitCode;
1041
+ }
1042
+ this.processes.delete(sessionId);
1043
+ this.persistState();
1044
+ });
1045
+
1046
+ this.persistState();
1047
+ }
1048
+
1049
+ private spawnAider(sessionId: string, info: SdkSessionInfo, options: LaunchOptions): void {
1050
+ let binary = options.aiderBinary || "aider";
1051
+ const resolved = resolveBinary(binary);
1052
+ if (resolved) {
1053
+ binary = resolved;
1054
+ } else {
1055
+ console.error(`[cli-launcher] Binary "${binary}" not found in PATH`);
1056
+ info.state = "exited";
1057
+ info.exitCode = 127;
1058
+ this.persistState();
1059
+ return;
1060
+ }
1061
+
1062
+ // Spawn: aider --no-pretty --yes --no-auto-commits [--model MODEL]
1063
+ const args: string[] = ["--no-pretty", "--yes", "--no-auto-commits"];
1064
+ if (options.model) {
1065
+ args.push("--model", options.model);
1066
+ }
1067
+
1068
+ const enrichedPath = getEnrichedPath();
1069
+ const env: Record<string, string | undefined> = {
1070
+ ...process.env,
1071
+ ...options.env,
1072
+ PATH: enrichedPath,
1073
+ };
1074
+
1075
+ console.log(`[cli-launcher] Spawning Aider session ${sessionId}: ${binary} ${args.join(" ")}`);
1076
+
1077
+ const proc = Bun.spawn([binary, ...args], {
1078
+ cwd: info.cwd,
1079
+ env,
1080
+ stdin: "pipe",
1081
+ stdout: "pipe",
1082
+ stderr: "pipe",
1083
+ });
1084
+
1085
+ info.pid = proc.pid;
1086
+ this.processes.set(sessionId, proc);
1087
+
1088
+ const adapter = new AiderAdapter(proc, sessionId, {
1089
+ model: options.model,
1090
+ cwd: info.cwd,
1091
+ permissionMode: options.permissionMode,
1092
+ recorder: this.recorder ?? undefined,
1093
+ });
1094
+
1095
+ if (this.onAdapter) {
1096
+ this.onAdapter(sessionId, adapter, "aider");
1097
+ }
1098
+
1099
+ info.state = "connected";
1100
+
1101
+ proc.exited.then((exitCode) => {
1102
+ console.log(`[cli-launcher] Aider session ${sessionId} exited (code=${exitCode})`);
1103
+ const session = this.sessions.get(sessionId);
1104
+ if (session) {
1105
+ session.state = "exited";
1106
+ session.exitCode = exitCode;
1107
+ }
1108
+ this.processes.delete(sessionId);
1109
+ this.persistState();
1110
+ });
1111
+
1112
+ this.persistState();
1113
+ }
1114
+
1115
+ private spawnOpenHands(sessionId: string, info: SdkSessionInfo, options: LaunchOptions): void {
1116
+ let binary = options.openhandsBinary || "openhands";
1117
+ const resolved = resolveBinary(binary);
1118
+ if (resolved) {
1119
+ binary = resolved;
1120
+ } else {
1121
+ console.error(`[cli-launcher] Binary "${binary}" not found in PATH`);
1122
+ info.state = "exited";
1123
+ info.exitCode = 127;
1124
+ this.persistState();
1125
+ return;
1126
+ }
1127
+
1128
+ // Spawn: openhands acp
1129
+ const args: string[] = ["acp"];
1130
+
1131
+ const enrichedPath = getEnrichedPath();
1132
+ const env: Record<string, string | undefined> = {
1133
+ ...process.env,
1134
+ ...options.env,
1135
+ PATH: enrichedPath,
1136
+ };
1137
+
1138
+ if (options.model) {
1139
+ env.OPENHANDS_MODEL = options.model;
1140
+ }
1141
+
1142
+ console.log(`[cli-launcher] Spawning OpenHands session ${sessionId}: ${binary} ${args.join(" ")}`);
1143
+
1144
+ const proc = Bun.spawn([binary, ...args], {
1145
+ cwd: info.cwd,
1146
+ env,
1147
+ stdin: "pipe",
1148
+ stdout: "pipe",
1149
+ stderr: "pipe",
1150
+ });
1151
+
1152
+ info.pid = proc.pid;
1153
+ this.processes.set(sessionId, proc);
1154
+
1155
+ // Pipe stderr for debugging
1156
+ const stderr = proc.stderr;
1157
+ if (stderr && typeof stderr !== "number") {
1158
+ this.pipeStream(sessionId, stderr, "stderr");
1159
+ }
1160
+
1161
+ const adapter = new OpenHandsAdapter(proc, sessionId, {
1162
+ model: options.model,
1163
+ cwd: info.cwd,
1164
+ permissionMode: options.permissionMode,
1165
+ recorder: this.recorder ?? undefined,
1166
+ });
1167
+
1168
+ adapter.onInitError((error) => {
1169
+ console.error(`[cli-launcher] OpenHands session ${sessionId} init failed: ${error}`);
1170
+ const session = this.sessions.get(sessionId);
1171
+ if (session) {
1172
+ session.state = "exited";
1173
+ session.exitCode = 1;
1174
+ }
1175
+ this.persistState();
1176
+ });
1177
+
1178
+ if (this.onAdapter) {
1179
+ this.onAdapter(sessionId, adapter, "openhands");
1180
+ }
1181
+
1182
+ info.state = "connected";
1183
+
1184
+ proc.exited.then((exitCode) => {
1185
+ console.log(`[cli-launcher] OpenHands session ${sessionId} exited (code=${exitCode})`);
1186
+ const session = this.sessions.get(sessionId);
1187
+ if (session) {
1188
+ session.state = "exited";
1189
+ session.exitCode = exitCode;
1190
+ }
1191
+ this.processes.delete(sessionId);
1192
+ this.persistState();
1193
+ });
1194
+
1195
+ this.persistState();
1196
+ }
1197
+
1198
+ /**
1199
+ * Spawn an OpenCode ACP subprocess for a session.
1200
+ * Uses stdio JSON-RPC (ACP) — same pattern as Goose and OpenHands.
1201
+ * CLI command: opencode acp
1202
+ */
1203
+ private spawnOpenCode(sessionId: string, info: SdkSessionInfo, options: LaunchOptions): void {
1204
+ let binary = options.opencodeBinary || "opencode";
1205
+ const resolved = resolveBinary(binary);
1206
+ if (resolved) {
1207
+ binary = resolved;
1208
+ } else {
1209
+ console.error(`[cli-launcher] Binary "${binary}" not found in PATH`);
1210
+ info.state = "exited";
1211
+ info.exitCode = 127;
1212
+ this.persistState();
1213
+ return;
1214
+ }
1215
+
1216
+ // Spawn: opencode acp
1217
+ const args: string[] = ["acp"];
1218
+
1219
+ const enrichedPath = getEnrichedPath();
1220
+ const env: Record<string, string | undefined> = {
1221
+ ...process.env,
1222
+ ...options.env,
1223
+ PATH: enrichedPath,
1224
+ };
1225
+
1226
+ // Set OpenCode model via environment if specified
1227
+ if (options.model) {
1228
+ env.OPENCODE_MODEL = options.model;
1229
+ }
1230
+
1231
+ console.log(`[cli-launcher] Spawning OpenCode session ${sessionId}: ${binary} ${args.join(" ")}`);
1232
+
1233
+ const proc = Bun.spawn([binary, ...args], {
1234
+ cwd: info.cwd,
1235
+ env,
1236
+ stdin: "pipe",
1237
+ stdout: "pipe",
1238
+ stderr: "pipe",
1239
+ });
1240
+
1241
+ info.pid = proc.pid;
1242
+ this.processes.set(sessionId, proc);
1243
+
1244
+ // Pipe stderr for debugging (stdout is used for JSON-RPC)
1245
+ const stderr = proc.stderr;
1246
+ if (stderr && typeof stderr !== "number") {
1247
+ this.pipeStream(sessionId, stderr, "stderr");
1248
+ }
1249
+
1250
+ // Create the OpenCodeAdapter which handles ACP JSON-RPC and message translation
1251
+ const adapter = new OpenCodeAdapter(proc, sessionId, {
1252
+ model: options.model,
1253
+ cwd: info.cwd,
1254
+ permissionMode: options.permissionMode,
1255
+ opencodeSessionId: info.cliSessionId,
1256
+ recorder: this.recorder ?? undefined,
1257
+ });
1258
+
1259
+ adapter.onInitError((error) => {
1260
+ console.error(`[cli-launcher] OpenCode session ${sessionId} init failed: ${error}`);
1261
+ const session = this.sessions.get(sessionId);
1262
+ if (session) {
1263
+ session.state = "exited";
1264
+ session.exitCode = 1;
1265
+ session.cliSessionId = undefined;
1266
+ }
1267
+ this.persistState();
1268
+ });
1269
+
1270
+ // Notify the WsBridge to attach this adapter
1271
+ if (this.onAdapter) {
1272
+ this.onAdapter(sessionId, adapter, "opencode");
1273
+ }
1274
+
1275
+ // Mark as connected immediately (no WS handshake needed for stdio)
1276
+ info.state = "connected";
1277
+
1278
+ // Monitor process exit
1279
+ proc.exited.then((exitCode) => {
1280
+ console.log(`[cli-launcher] OpenCode session ${sessionId} exited (code=${exitCode})`);
1281
+ const session = this.sessions.get(sessionId);
1282
+ if (session) {
1283
+ session.state = "exited";
1284
+ session.exitCode = exitCode;
1285
+ }
1286
+ this.processes.delete(sessionId);
1287
+ this.persistState();
1288
+ });
1289
+
1290
+ this.persistState();
1291
+ }
1292
+
1293
+ private spawnOpenClaw(sessionId: string, info: SdkSessionInfo, options: LaunchOptions): void {
1294
+ let binary = options.openclawBinary || "openclaw";
1295
+ const resolved = resolveBinary(binary);
1296
+ if (resolved) {
1297
+ binary = resolved;
1298
+ } else {
1299
+ console.error(`[cli-launcher] Binary "${binary}" not found in PATH`);
1300
+ info.state = "exited";
1301
+ info.exitCode = 127;
1302
+ this.persistState();
1303
+ return;
1304
+ }
1305
+
1306
+ // Spawn: openclaw acp
1307
+ const args: string[] = ["acp"];
1308
+
1309
+ const enrichedPath = getEnrichedPath();
1310
+ const env: Record<string, string | undefined> = {
1311
+ ...process.env,
1312
+ ...options.env,
1313
+ PATH: enrichedPath,
1314
+ };
1315
+
1316
+ // Pass Gateway URL and token via environment if available
1317
+ if (options.model) {
1318
+ env.OPENCLAW_MODEL = options.model;
1319
+ }
1320
+
1321
+ console.log(`[cli-launcher] Spawning OpenClaw session ${sessionId}: ${binary} ${args.join(" ")}`);
1322
+
1323
+ const proc = Bun.spawn([binary, ...args], {
1324
+ cwd: info.cwd,
1325
+ env,
1326
+ stdin: "pipe",
1327
+ stdout: "pipe",
1328
+ stderr: "pipe",
1329
+ });
1330
+
1331
+ info.pid = proc.pid;
1332
+ this.processes.set(sessionId, proc);
1333
+
1334
+ // Pipe stderr for debugging
1335
+ const stderr = proc.stderr;
1336
+ if (stderr && typeof stderr !== "number") {
1337
+ this.pipeStream(sessionId, stderr, "stderr");
1338
+ }
1339
+
1340
+ const adapter = new OpenClawAdapter(proc, sessionId, {
1341
+ model: options.model,
1342
+ cwd: info.cwd,
1343
+ permissionMode: options.permissionMode,
1344
+ openclawSessionId: info.cliSessionId,
1345
+ recorder: this.recorder ?? undefined,
1346
+ });
1347
+
1348
+ adapter.onInitError((error) => {
1349
+ console.error(`[cli-launcher] OpenClaw session ${sessionId} init failed: ${error}`);
1350
+ const session = this.sessions.get(sessionId);
1351
+ if (session) {
1352
+ session.state = "exited";
1353
+ session.exitCode = 1;
1354
+ session.cliSessionId = undefined;
1355
+ }
1356
+ this.persistState();
1357
+ });
1358
+
1359
+ if (this.onAdapter) {
1360
+ this.onAdapter(sessionId, adapter, "openclaw");
1361
+ }
1362
+
1363
+ info.state = "connected";
1364
+
1365
+ proc.exited.then((exitCode) => {
1366
+ console.log(`[cli-launcher] OpenClaw session ${sessionId} exited (code=${exitCode})`);
1367
+ const session = this.sessions.get(sessionId);
1368
+ if (session) {
1369
+ session.state = "exited";
1370
+ session.exitCode = exitCode;
1371
+ }
1372
+ this.processes.delete(sessionId);
1373
+ this.persistState();
1374
+ });
1375
+
1376
+ this.persistState();
1377
+ }
1378
+
1379
+ /**
1380
+ * Inject a CLAUDE.md file into the worktree with branch guardrails.
1381
+ * Only injects into actual worktree directories, never the main repo.
1382
+ */
1383
+ private injectWorktreeGuardrails(worktreePath: string, branch: string, repoRoot: string, parentBranch?: string): void {
1384
+ // Safety: never inject guardrails into the main repository itself
1385
+ if (worktreePath === repoRoot) {
1386
+ console.warn(`[cli-launcher] Skipping guardrails injection: worktree path is the main repo (${repoRoot})`);
1387
+ return;
1388
+ }
1389
+
1390
+ // Safety: only inject if the worktree directory actually exists (created by git worktree add)
1391
+ if (!existsSync(worktreePath)) {
1392
+ console.warn(`[cli-launcher] Skipping guardrails injection: worktree path does not exist (${worktreePath})`);
1393
+ return;
1394
+ }
1395
+
1396
+ const branchLabel = parentBranch
1397
+ ? `\`${branch}\` (created from \`${parentBranch}\`)`
1398
+ : `\`${branch}\``;
1399
+
1400
+ const MARKER_START = "<!-- WORKTREE_GUARDRAILS_START -->";
1401
+ const MARKER_END = "<!-- WORKTREE_GUARDRAILS_END -->";
1402
+ const guardrails = `${MARKER_START}
1403
+ # Worktree Session — Branch Guardrails
1404
+
1405
+ You are working on branch: ${branchLabel}
1406
+ This is a git worktree. The main repository is at: \`${repoRoot}\`
1407
+
1408
+ **Rules:**
1409
+ 1. DO NOT run \`git checkout\`, \`git switch\`, or any command that changes the current branch
1410
+ 2. All your work MUST stay on the \`${branch}\` branch
1411
+ 3. When committing, commit to \`${branch}\` only
1412
+ 4. If you need to reference code from another branch, use \`git show other-branch:path/to/file\`
1413
+ ${MARKER_END}`;
1414
+
1415
+ const claudeDir = join(worktreePath, ".claude");
1416
+ const claudeMdPath = join(claudeDir, "CLAUDE.md");
1417
+
1418
+ try {
1419
+ mkdirSync(claudeDir, { recursive: true });
1420
+
1421
+ if (existsSync(claudeMdPath)) {
1422
+ const existing = readFileSync(claudeMdPath, "utf-8");
1423
+ // Replace existing guardrails section or append
1424
+ if (existing.includes(MARKER_START)) {
1425
+ const before = existing.substring(0, existing.indexOf(MARKER_START));
1426
+ const afterIdx = existing.indexOf(MARKER_END);
1427
+ const after = afterIdx >= 0 ? existing.substring(afterIdx + MARKER_END.length) : "";
1428
+ writeFileSync(claudeMdPath, before + guardrails + after, "utf-8");
1429
+ } else {
1430
+ writeFileSync(claudeMdPath, existing + "\n\n" + guardrails, "utf-8");
1431
+ }
1432
+ } else {
1433
+ writeFileSync(claudeMdPath, guardrails, "utf-8");
1434
+ }
1435
+ console.log(`[cli-launcher] Injected worktree guardrails for branch ${branch}`);
1436
+ } catch (e) {
1437
+ console.warn(`[cli-launcher] Failed to inject worktree guardrails:`, e);
1438
+ }
1439
+ }
1440
+
1441
+ /**
1442
+ * Mark a session as connected (called when CLI establishes WS connection).
1443
+ */
1444
+ markConnected(sessionId: string): void {
1445
+ const session = this.sessions.get(sessionId);
1446
+ if (session && (session.state === "starting" || session.state === "connected")) {
1447
+ session.state = "connected";
1448
+ console.log(`[cli-launcher] Session ${sessionId} connected via WebSocket`);
1449
+ this.persistState();
1450
+ }
1451
+ }
1452
+
1453
+ /**
1454
+ * Store the CLI's internal session ID (from system.init message).
1455
+ * This is needed for --resume on relaunch.
1456
+ */
1457
+ setCLISessionId(sessionId: string, cliSessionId: string): void {
1458
+ const session = this.sessions.get(sessionId);
1459
+ if (session) {
1460
+ session.cliSessionId = cliSessionId;
1461
+ this.persistState();
1462
+ }
1463
+ }
1464
+
1465
+ /**
1466
+ * Kill a session's CLI process.
1467
+ */
1468
+ async kill(sessionId: string): Promise<boolean> {
1469
+ const proc = this.processes.get(sessionId);
1470
+ if (!proc) return false;
1471
+
1472
+ proc.kill("SIGTERM");
1473
+
1474
+ // Wait up to 5s for graceful exit, then force kill
1475
+ const exited = await Promise.race([
1476
+ proc.exited.then(() => true),
1477
+ new Promise<false>((resolve) => setTimeout(() => resolve(false), 5_000)),
1478
+ ]);
1479
+
1480
+ if (!exited) {
1481
+ console.log(`[cli-launcher] Force-killing session ${sessionId}`);
1482
+ proc.kill("SIGKILL");
1483
+ }
1484
+
1485
+ const session = this.sessions.get(sessionId);
1486
+ if (session) {
1487
+ session.state = "exited";
1488
+ session.exitCode = -1;
1489
+ }
1490
+ this.processes.delete(sessionId);
1491
+ this.persistState();
1492
+ return true;
1493
+ }
1494
+
1495
+ /**
1496
+ * List all sessions (active + recently exited).
1497
+ */
1498
+ listSessions(): SdkSessionInfo[] {
1499
+ return Array.from(this.sessions.values());
1500
+ }
1501
+
1502
+ /**
1503
+ * Get a specific session.
1504
+ */
1505
+ getSession(sessionId: string): SdkSessionInfo | undefined {
1506
+ return this.sessions.get(sessionId);
1507
+ }
1508
+
1509
+ /**
1510
+ * Mark a session as exited without requiring a live child process.
1511
+ * Used when an automatic relaunch is known to be impossible, such as when
1512
+ * the persisted working directory was removed.
1513
+ */
1514
+ markSessionExited(sessionId: string, exitCode: number | null = 1): boolean {
1515
+ const session = this.sessions.get(sessionId);
1516
+ if (!session) return false;
1517
+ session.state = "exited";
1518
+ session.exitCode = exitCode;
1519
+ session.pid = undefined;
1520
+ this.processes.delete(sessionId);
1521
+ this.persistState();
1522
+ return true;
1523
+ }
1524
+
1525
+ /**
1526
+ * Check if a session exists and is alive (not exited).
1527
+ */
1528
+ isAlive(sessionId: string): boolean {
1529
+ const session = this.sessions.get(sessionId);
1530
+ return !!session && session.state !== "exited";
1531
+ }
1532
+
1533
+ /**
1534
+ * Set the archived flag on a session.
1535
+ */
1536
+ setArchived(sessionId: string, archived: boolean): void {
1537
+ const info = this.sessions.get(sessionId);
1538
+ if (info) {
1539
+ info.archived = archived;
1540
+ this.persistState();
1541
+ }
1542
+ }
1543
+
1544
+ /**
1545
+ * Remove a session from the internal map (after kill or cleanup).
1546
+ */
1547
+ removeSession(sessionId: string) {
1548
+ this.sessions.delete(sessionId);
1549
+ this.processes.delete(sessionId);
1550
+ this.persistState();
1551
+ }
1552
+
1553
+ /**
1554
+ * Remove exited sessions from the list.
1555
+ */
1556
+ pruneExited(): number {
1557
+ let pruned = 0;
1558
+ for (const [id, session] of this.sessions) {
1559
+ if (session.state === "exited") {
1560
+ this.sessions.delete(id);
1561
+ pruned++;
1562
+ }
1563
+ }
1564
+ return pruned;
1565
+ }
1566
+
1567
+ /**
1568
+ * Kill all sessions.
1569
+ */
1570
+ async killAll(): Promise<void> {
1571
+ const ids = [...this.processes.keys()];
1572
+ await Promise.all(ids.map((id) => this.kill(id)));
1573
+ }
1574
+
1575
+ private async pipeStream(
1576
+ sessionId: string,
1577
+ stream: ReadableStream<Uint8Array> | null,
1578
+ label: "stdout" | "stderr",
1579
+ ): Promise<void> {
1580
+ if (!stream) return;
1581
+ const reader = stream.getReader();
1582
+ const decoder = new TextDecoder();
1583
+ const log = label === "stdout" ? console.log : console.error;
1584
+ try {
1585
+ while (true) {
1586
+ const { done, value } = await reader.read();
1587
+ if (done) break;
1588
+ const text = decoder.decode(value);
1589
+ if (text.trim()) {
1590
+ log(`[session:${sessionId}:${label}] ${text.trimEnd()}`);
1591
+ }
1592
+ }
1593
+ } catch {
1594
+ // stream closed
1595
+ }
1596
+ }
1597
+
1598
+ private pipeOutput(sessionId: string, proc: Subprocess): void {
1599
+ const stdout = proc.stdout;
1600
+ const stderr = proc.stderr;
1601
+ if (stdout && typeof stdout !== "number") {
1602
+ this.pipeStream(sessionId, stdout, "stdout");
1603
+ }
1604
+ if (stderr && typeof stderr !== "number") {
1605
+ this.pipeStream(sessionId, stderr, "stderr");
1606
+ }
1607
+ }
1608
+
1609
+ /** Get the port this launcher is using. */
1610
+ getPort(): number {
1611
+ return this.port;
1612
+ }
1613
+ }