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.
- package/bin/cli.ts +182 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/MermaidDiagram-By1J3Whj.js +2 -0
- package/dist/assets/_basePickBy-Dq6gnD-p.js +1 -0
- package/dist/assets/_baseUniq-SpgN6cGc.js +1 -0
- package/dist/assets/arc-D9SEA8dX.js +1 -0
- package/dist/assets/architectureDiagram-VXUJARFQ-CoTcwLeR.js +36 -0
- package/dist/assets/blockDiagram-VD42YOAC-Dvc63voV.js +122 -0
- package/dist/assets/c4Diagram-YG6GDRKO-tuDcG4_4.js +10 -0
- package/dist/assets/channel-CxhcHn7S.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-x1hChYu9.js +1 -0
- package/dist/assets/chunk-55IACEB6-BX6HWnWP.js +1 -0
- package/dist/assets/chunk-B4BG7PRW-B4IlF03G.js +165 -0
- package/dist/assets/chunk-DI55MBZ5-TpYb5rLg.js +220 -0
- package/dist/assets/chunk-FMBD7UC4-DZX4LUPx.js +15 -0
- package/dist/assets/chunk-QN33PNHL-CuQs0NDY.js +1 -0
- package/dist/assets/chunk-QZHKN3VN-CygWwVx8.js +1 -0
- package/dist/assets/chunk-TZMSLE5B-CtI-IdKV.js +1 -0
- package/dist/assets/classDiagram-2ON5EDUG-BM0LptJF.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-BM0LptJF.js +1 -0
- package/dist/assets/clone-C6kt1rE7.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-PdySs991.js +1 -0
- package/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
- package/dist/assets/dagre-6UL2VRFP-J0SxEiEu.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-PSM6KHXK-CW0HLXaH.js +24 -0
- package/dist/assets/diagram-QEK2KX5R-DADHF-k4.js +43 -0
- package/dist/assets/diagram-S2PKOQOG-9S0-PC3S.js +24 -0
- package/dist/assets/erDiagram-Q2GNP2WA-BEHAiY_q.js +60 -0
- package/dist/assets/flowDiagram-NV44I4VS-Cu6n9Xnd.js +162 -0
- package/dist/assets/ganttDiagram-JELNMOA3-Cr_BHryu.js +267 -0
- package/dist/assets/gitGraphDiagram-V2S2FVAM-D5pT35v5.js +65 -0
- package/dist/assets/graph-DKRbrxcs.js +1 -0
- package/dist/assets/index-Byjg9zgI.css +32 -0
- package/dist/assets/index-Cwj3m0hT.js +284 -0
- package/dist/assets/infoDiagram-HS3SLOUP-BaJlrgKV.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/journeyDiagram-XKPGCS4Q-CXC4-DJv.js +139 -0
- package/dist/assets/kanban-definition-3W4ZIXB7-BtHBu9cx.js +89 -0
- package/dist/assets/katex-BlHpptmG.js +261 -0
- package/dist/assets/layout-DnxSwLDJ.js +1 -0
- package/dist/assets/linear-CWbbIT8l.js +1 -0
- package/dist/assets/mermaid.core-DXn1cFAK.js +256 -0
- package/dist/assets/mindmap-definition-VGOIOE7T-BR8br1hs.js +68 -0
- package/dist/assets/ordinal-BENe2yWM.js +1 -0
- package/dist/assets/pieDiagram-ADFJNKIX-B7RmXV7I.js +30 -0
- package/dist/assets/quadrantDiagram-AYHSOK5B-DK0FhyV_.js +7 -0
- package/dist/assets/requirementDiagram-UZGBJVZJ-3zAGxSpL.js +64 -0
- package/dist/assets/sankeyDiagram-TZEHDZUN-DZppKtjc.js +10 -0
- package/dist/assets/sequenceDiagram-WL72ISMW-D1x3r3wV.js +145 -0
- package/dist/assets/stateDiagram-FKZM4ZOC-2ByD08Vq.js +1 -0
- package/dist/assets/stateDiagram-v2-4FDKWEC3-Vm1cYjgV.js +1 -0
- package/dist/assets/timeline-definition-IT6M3QCI-l5S35biR.js +61 -0
- package/dist/assets/treemap-GDKQZRPO-DIPadPiF.js +162 -0
- package/dist/assets/xychartDiagram-PRI3JC2R-9GUvwS2b.js +7 -0
- package/dist/favicon.svg +3 -0
- package/dist/fonts/Geist-Variable.woff2 +0 -0
- package/dist/fonts/GeistMono-Variable.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +33 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo.svg +3 -0
- package/dist/manifest.json +26 -0
- package/dist/og-image.png +0 -0
- package/dist/sw.js +130 -0
- package/package.json +92 -0
- package/server/ADAPTERS.md +121 -0
- package/server/adapter-registry-types.ts +28 -0
- package/server/adapter-registry.ts +293 -0
- package/server/adapter-types.ts +51 -0
- package/server/agent-executor.ts +249 -0
- package/server/agent-mcp-bridge.ts +148 -0
- package/server/agent-mcp-stdio.ts +171 -0
- package/server/agent-mcp-tools.ts +76 -0
- package/server/agent-store.ts +183 -0
- package/server/agent-types.ts +79 -0
- package/server/aider-adapter.ts +525 -0
- package/server/auth.ts +153 -0
- package/server/auto-namer.ts +132 -0
- package/server/capability-discovery.ts +431 -0
- package/server/claude-container-auth.ts +40 -0
- package/server/claude-stdio-adapter.ts +452 -0
- package/server/clawhub-export.ts +218 -0
- package/server/cli-launcher.ts +1613 -0
- package/server/codex-adapter.ts +1987 -0
- package/server/codex-container-auth.ts +40 -0
- package/server/codex-home.ts +26 -0
- package/server/collective-intelligence.ts +401 -0
- package/server/commands-discovery.ts +160 -0
- package/server/constants.ts +5 -0
- package/server/container-manager.ts +328 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +149 -0
- package/server/cron-types.ts +63 -0
- package/server/deliberation-engine.ts +323 -0
- package/server/embedding.ts +89 -0
- package/server/env-manager.ts +146 -0
- package/server/environment-detector.ts +106 -0
- package/server/environment-rules.ts +106 -0
- package/server/gallery-store.ts +216 -0
- package/server/gallery-types.ts +52 -0
- package/server/gallery-votes.ts +101 -0
- package/server/git-utils.ts +384 -0
- package/server/github-pr.ts +379 -0
- package/server/goose-adapter.ts +929 -0
- package/server/image-pull-manager.ts +144 -0
- package/server/index.ts +363 -0
- package/server/linear-project-manager.ts +78 -0
- package/server/moltbook-client.ts +160 -0
- package/server/openclaw-adapter.ts +916 -0
- package/server/openclaw-channel/channel.ts +188 -0
- package/server/openclaw-channel/index.ts +68 -0
- package/server/openclaw-channel/package.json +19 -0
- package/server/openclaw-channel/types.ts +93 -0
- package/server/opencode-adapter.ts +951 -0
- package/server/openhands-adapter.ts +775 -0
- package/server/orchestrator-executor.ts +184 -0
- package/server/orchestrator-store.ts +106 -0
- package/server/orchestrator-types.ts +86 -0
- package/server/path-resolver.ts +168 -0
- package/server/pr-poller.ts +162 -0
- package/server/proactive-keepalive.ts +121 -0
- package/server/prompt-manager.ts +103 -0
- package/server/protocol-monitor.ts +197 -0
- package/server/race-controller.ts +295 -0
- package/server/race-store.ts +82 -0
- package/server/recorder.ts +359 -0
- package/server/recording-hub/compat-validator.ts +122 -0
- package/server/recording-hub/diagnostics.ts +289 -0
- package/server/recording-hub/hub-routes.ts +146 -0
- package/server/recording-hub/hub-store.ts +224 -0
- package/server/replay.ts +78 -0
- package/server/routes/adapter-routes.ts +31 -0
- package/server/routes/agent-mcp-routes.ts +35 -0
- package/server/routes/agent-routes.ts +185 -0
- package/server/routes/auth-routes.ts +101 -0
- package/server/routes/ci-routes.ts +165 -0
- package/server/routes/commands-routes.ts +94 -0
- package/server/routes/cron-routes.ts +100 -0
- package/server/routes/env-routes.ts +50 -0
- package/server/routes/folder-routes.ts +56 -0
- package/server/routes/fs-routes.ts +403 -0
- package/server/routes/gallery-routes.ts +410 -0
- package/server/routes/git-routes.ts +108 -0
- package/server/routes/index.ts +62 -0
- package/server/routes/linear-routes.ts +254 -0
- package/server/routes/monitor-routes.ts +11 -0
- package/server/routes/orchestrator-routes.ts +210 -0
- package/server/routes/prompt-routes.ts +47 -0
- package/server/routes/race-routes.ts +103 -0
- package/server/routes/recording-routes.ts +101 -0
- package/server/routes/route-deps.ts +32 -0
- package/server/routes/session-routes.ts +560 -0
- package/server/routes/settings-routes.ts +108 -0
- package/server/routes/skills-routes.ts +52 -0
- package/server/routes/system-routes.ts +243 -0
- package/server/routes/webhook-routes.ts +135 -0
- package/server/routes.ts +44 -0
- package/server/security-middleware.ts +100 -0
- package/server/semantic-memory.ts +389 -0
- package/server/service.ts +733 -0
- package/server/session-folders.ts +107 -0
- package/server/session-git-info.ts +89 -0
- package/server/session-linear-issues.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-store.ts +169 -0
- package/server/session-types.ts +415 -0
- package/server/settings-manager.ts +120 -0
- package/server/shared-context.ts +401 -0
- package/server/skills-manager.ts +250 -0
- package/server/sub-session-manager.ts +263 -0
- package/server/terminal-manager.ts +185 -0
- package/server/update-checker.ts +114 -0
- package/server/usage-limits.ts +192 -0
- package/server/webhook-manager.ts +228 -0
- package/server/webhook-store.ts +168 -0
- package/server/webhook-types.ts +66 -0
- package/server/worktree-tracker.ts +84 -0
- 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
|
+
}
|