heyhank 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/README.md +40 -0
  2. package/bin/cli.ts +168 -0
  3. package/bin/ctl.ts +528 -0
  4. package/bin/generate-token.ts +28 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
  7. package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
  8. package/dist/assets/CronManager-DDbz-yiT.js +1 -0
  9. package/dist/assets/HelpPage-DMfkzERp.js +1 -0
  10. package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
  11. package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
  12. package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
  13. package/dist/assets/Playground-Fc5cdc5p.js +109 -0
  14. package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
  15. package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
  16. package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
  17. package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
  18. package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
  19. package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
  20. package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
  21. package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
  22. package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
  23. package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
  24. package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
  25. package/dist/assets/index-C8M_PUmX.css +32 -0
  26. package/dist/assets/index-CEqZnThB.js +204 -0
  27. package/dist/assets/sw-register-LSSpj6RU.js +1 -0
  28. package/dist/assets/time-ago-B6r_l9u1.js +1 -0
  29. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  30. package/dist/favicon-32-original.png +0 -0
  31. package/dist/favicon-32.png +0 -0
  32. package/dist/favicon.ico +0 -0
  33. package/dist/favicon.svg +8 -0
  34. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  35. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  36. package/dist/heyhank-mascot-poster.png +0 -0
  37. package/dist/heyhank-mascot.mp4 +0 -0
  38. package/dist/heyhank-mascot.webm +0 -0
  39. package/dist/icon-192-original.png +0 -0
  40. package/dist/icon-192.png +0 -0
  41. package/dist/icon-512-original.png +0 -0
  42. package/dist/icon-512.png +0 -0
  43. package/dist/index.html +21 -0
  44. package/dist/logo-192.png +0 -0
  45. package/dist/logo-512.png +0 -0
  46. package/dist/logo-codex.svg +14 -0
  47. package/dist/logo-docker.svg +4 -0
  48. package/dist/logo-original.png +0 -0
  49. package/dist/logo.png +0 -0
  50. package/dist/logo.svg +14 -0
  51. package/dist/manifest.json +24 -0
  52. package/dist/push-sw.js +34 -0
  53. package/dist/sw.js +1 -0
  54. package/dist/workbox-d2a0910a.js +1 -0
  55. package/package.json +109 -0
  56. package/server/agent-cron-migrator.ts +85 -0
  57. package/server/agent-executor.ts +357 -0
  58. package/server/agent-store.ts +185 -0
  59. package/server/agent-timeout.ts +107 -0
  60. package/server/agent-types.ts +122 -0
  61. package/server/ai-validation-settings.ts +37 -0
  62. package/server/ai-validator.ts +181 -0
  63. package/server/anthropic-provider-migration.ts +48 -0
  64. package/server/assistant-store.ts +272 -0
  65. package/server/auth-manager.ts +150 -0
  66. package/server/auto-approve.ts +153 -0
  67. package/server/auto-namer.ts +36 -0
  68. package/server/backend-adapter.ts +54 -0
  69. package/server/cache-headers.ts +61 -0
  70. package/server/calendar-service.ts +434 -0
  71. package/server/claude-adapter.ts +889 -0
  72. package/server/claude-container-auth.ts +30 -0
  73. package/server/claude-session-discovery.ts +157 -0
  74. package/server/claude-session-history.ts +410 -0
  75. package/server/cli-launcher.ts +1303 -0
  76. package/server/codex-adapter.ts +3027 -0
  77. package/server/codex-container-auth.ts +24 -0
  78. package/server/codex-home.ts +27 -0
  79. package/server/codex-ws-proxy.cjs +226 -0
  80. package/server/commands-discovery.ts +81 -0
  81. package/server/constants.ts +7 -0
  82. package/server/container-manager.ts +1053 -0
  83. package/server/cost-tracker.ts +222 -0
  84. package/server/cron-scheduler.ts +243 -0
  85. package/server/cron-store.ts +148 -0
  86. package/server/cron-types.ts +63 -0
  87. package/server/email-service.ts +354 -0
  88. package/server/env-manager.ts +161 -0
  89. package/server/event-bus-types.ts +75 -0
  90. package/server/event-bus.ts +124 -0
  91. package/server/execution-store.ts +170 -0
  92. package/server/federation/node-connection.ts +190 -0
  93. package/server/federation/node-manager.ts +366 -0
  94. package/server/federation/node-store.ts +86 -0
  95. package/server/federation/node-types.ts +121 -0
  96. package/server/fs-utils.ts +15 -0
  97. package/server/git-utils.ts +421 -0
  98. package/server/github-pr.ts +379 -0
  99. package/server/google-media.ts +342 -0
  100. package/server/image-pull-manager.ts +279 -0
  101. package/server/index.ts +491 -0
  102. package/server/internal-ai.ts +237 -0
  103. package/server/kill-switch.ts +99 -0
  104. package/server/llm-providers.ts +342 -0
  105. package/server/logger.ts +259 -0
  106. package/server/mcp-registry.ts +401 -0
  107. package/server/message-bus.ts +271 -0
  108. package/server/message-delivery.ts +128 -0
  109. package/server/metrics-collector.ts +350 -0
  110. package/server/metrics-types.ts +108 -0
  111. package/server/middleware/managed-auth.ts +195 -0
  112. package/server/novnc-proxy.ts +99 -0
  113. package/server/path-resolver.ts +186 -0
  114. package/server/paths.ts +13 -0
  115. package/server/pr-poller.ts +162 -0
  116. package/server/prompt-manager.ts +211 -0
  117. package/server/protocol/claude-upstream/README.md +19 -0
  118. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  119. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  120. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  121. package/server/protocol/codex-upstream/README.md +18 -0
  122. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  123. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  124. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  125. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  126. package/server/protocol-monitor.ts +50 -0
  127. package/server/provider-manager.ts +111 -0
  128. package/server/provider-registry.ts +393 -0
  129. package/server/push-notifications.ts +221 -0
  130. package/server/recorder.ts +374 -0
  131. package/server/recording-hub/compat-validator.ts +284 -0
  132. package/server/recording-hub/diagnostics.ts +299 -0
  133. package/server/recording-hub/hub-config.ts +19 -0
  134. package/server/recording-hub/hub-routes.ts +236 -0
  135. package/server/recording-hub/hub-store.ts +265 -0
  136. package/server/recording-hub/replay-adapter.ts +207 -0
  137. package/server/relay-client.ts +320 -0
  138. package/server/reminder-scheduler.ts +38 -0
  139. package/server/replay.ts +78 -0
  140. package/server/routes/agent-routes.ts +264 -0
  141. package/server/routes/assistant-routes.ts +90 -0
  142. package/server/routes/cron-routes.ts +103 -0
  143. package/server/routes/env-routes.ts +95 -0
  144. package/server/routes/federation-routes.ts +76 -0
  145. package/server/routes/fs-routes.ts +622 -0
  146. package/server/routes/git-routes.ts +97 -0
  147. package/server/routes/llm-routes.ts +166 -0
  148. package/server/routes/media-routes.ts +135 -0
  149. package/server/routes/metrics-routes.ts +13 -0
  150. package/server/routes/platform-routes.ts +1379 -0
  151. package/server/routes/prompt-routes.ts +67 -0
  152. package/server/routes/provider-routes.ts +109 -0
  153. package/server/routes/sandbox-routes.ts +127 -0
  154. package/server/routes/settings-routes.ts +285 -0
  155. package/server/routes/skills-routes.ts +100 -0
  156. package/server/routes/socialmedia-routes.ts +208 -0
  157. package/server/routes/system-routes.ts +228 -0
  158. package/server/routes/tailscale-routes.ts +22 -0
  159. package/server/routes/telephony-routes.ts +259 -0
  160. package/server/routes.ts +1379 -0
  161. package/server/sandbox-manager.ts +168 -0
  162. package/server/service.ts +718 -0
  163. package/server/session-creation-service.ts +457 -0
  164. package/server/session-git-info.ts +104 -0
  165. package/server/session-names.ts +67 -0
  166. package/server/session-orchestrator.ts +824 -0
  167. package/server/session-state-machine.ts +207 -0
  168. package/server/session-store.ts +146 -0
  169. package/server/session-types.ts +511 -0
  170. package/server/settings-manager.ts +149 -0
  171. package/server/shared-context.ts +157 -0
  172. package/server/socialmedia/adapter.ts +15 -0
  173. package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
  174. package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
  175. package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
  176. package/server/socialmedia/manager.ts +227 -0
  177. package/server/socialmedia/store.ts +98 -0
  178. package/server/socialmedia/types.ts +89 -0
  179. package/server/tailscale-manager.ts +451 -0
  180. package/server/telephony/audio-bridge.ts +331 -0
  181. package/server/telephony/call-manager.ts +457 -0
  182. package/server/telephony/call-types.ts +108 -0
  183. package/server/telephony/telephony-store.ts +119 -0
  184. package/server/terminal-manager.ts +240 -0
  185. package/server/update-checker.ts +192 -0
  186. package/server/usage-limits.ts +225 -0
  187. package/server/web-push.d.ts +51 -0
  188. package/server/worktree-tracker.ts +84 -0
  189. package/server/ws-auth.ts +41 -0
  190. package/server/ws-bridge-browser-ingest.ts +72 -0
  191. package/server/ws-bridge-browser.ts +112 -0
  192. package/server/ws-bridge-cli-ingest.ts +81 -0
  193. package/server/ws-bridge-codex.ts +266 -0
  194. package/server/ws-bridge-controls.ts +20 -0
  195. package/server/ws-bridge-persist.ts +66 -0
  196. package/server/ws-bridge-publish.ts +79 -0
  197. package/server/ws-bridge-replay.ts +61 -0
  198. package/server/ws-bridge-types.ts +121 -0
  199. package/server/ws-bridge.ts +1240 -0
@@ -0,0 +1,824 @@
1
+ import type { CliLauncher, SdkSessionInfo } from "./cli-launcher.js";
2
+ import type { WsBridge } from "./ws-bridge.js";
3
+ import type { SessionStore } from "./session-store.js";
4
+ import type { WorktreeTracker } from "./worktree-tracker.js";
5
+ import type { AgentExecutor } from "./agent-executor.js";
6
+ import type { BackendType, CreationStepId } from "./session-types.js";
7
+ import type { ContainerConfig, ContainerInfo } from "./container-manager.js";
8
+ import { containerManager } from "./container-manager.js";
9
+ import { imagePullManager } from "./image-pull-manager.js";
10
+ import * as envManager from "./env-manager.js";
11
+ import * as sandboxManager from "./sandbox-manager.js";
12
+ import * as gitUtils from "./git-utils.js";
13
+ import * as sessionNames from "./session-names.js";
14
+ import { hasContainerClaudeAuth } from "./claude-container-auth.js";
15
+ import { hasContainerCodexAuth } from "./codex-container-auth.js";
16
+ import { discoverCommandsAndSkills } from "./commands-discovery.js";
17
+ import { getSettings } from "./settings-manager.js";
18
+ import { generateSessionTitle } from "./auto-namer.js";
19
+ import { heyHankBus } from "./event-bus.js";
20
+ import { metricsCollector } from "./metrics-collector.js";
21
+ import { log } from "./logger.js";
22
+
23
+ // ── Constants ────────────────────────────────────────────────────────────────
24
+
25
+ const MAX_AUTO_RELAUNCHES = 3;
26
+ const RELAUNCH_GRACE_MS = 10_000;
27
+ const RELAUNCH_COOLDOWN_MS = 5_000;
28
+ const RECONNECT_GRACE_MS = Number(process.env.HEYHANK_RECONNECT_GRACE_MS || process.env.COMPANION_RECONNECT_GRACE_MS || "30000");
29
+
30
+ const VSCODE_EDITOR_CONTAINER_PORT = 13337;
31
+ const CODEX_APP_SERVER_CONTAINER_PORT = Number(
32
+ process.env.HEYHANK_CODEX_CONTAINER_WS_PORT || process.env.COMPANION_CODEX_CONTAINER_WS_PORT || "4502",
33
+ );
34
+ const NOVNC_CONTAINER_PORT = 6080;
35
+
36
+ // ── Types ────────────────────────────────────────────────────────────────────
37
+
38
+ export interface SessionOrchestratorDeps {
39
+ launcher: CliLauncher;
40
+ wsBridge: WsBridge;
41
+ sessionStore: SessionStore;
42
+ worktreeTracker: WorktreeTracker;
43
+ prPoller: {
44
+ watch(sessionId: string, cwd: string, branch: string): void;
45
+ unwatch(sessionId: string): void;
46
+ };
47
+ agentExecutor: AgentExecutor;
48
+ }
49
+
50
+ export interface CreateSessionRequest {
51
+ backend?: string;
52
+ model?: string;
53
+ permissionMode?: string;
54
+ cwd?: string;
55
+ claudeBinary?: string;
56
+ codexBinary?: string;
57
+ allowedTools?: string[];
58
+ env?: Record<string, string>;
59
+ envSlug?: string;
60
+ sandboxEnabled?: boolean;
61
+ sandboxSlug?: string;
62
+ branch?: string;
63
+ createBranch?: boolean;
64
+ useWorktree?: boolean;
65
+ container?: { image?: string; ports?: number[]; volumes?: string[] };
66
+ resumeSessionAt?: string;
67
+ forkSession?: boolean;
68
+ }
69
+
70
+ export type CreateSessionResult =
71
+ | { ok: true; session: SdkSessionInfo }
72
+ | { ok: false; error: string; status: number };
73
+
74
+ export type ProgressCallback = (
75
+ step: CreationStepId,
76
+ label: string,
77
+ status: "in_progress" | "done" | "error",
78
+ detail?: string,
79
+ ) => Promise<void>;
80
+
81
+ export interface ArchiveSessionOptions {
82
+ force?: boolean;
83
+ }
84
+
85
+ export interface ArchiveSessionResult {
86
+ ok: boolean;
87
+ worktree?: { cleaned?: boolean; dirty?: boolean; path?: string };
88
+ }
89
+
90
+ export interface DeleteSessionResult {
91
+ ok: boolean;
92
+ worktree?: { cleaned?: boolean; dirty?: boolean; path?: string };
93
+ }
94
+
95
+ // ── Orchestrator ─────────────────────────────────────────────────────────────
96
+
97
+ /**
98
+ * Single entry point for session lifecycle operations: create, resume,
99
+ * reconnect, and terminate. Coordinates between CliLauncher (process
100
+ * management), WsBridge (message routing), and SessionStore (persistence).
101
+ */
102
+ export class SessionOrchestrator {
103
+ private launcher: CliLauncher;
104
+ private wsBridge: WsBridge;
105
+ private sessionStore: SessionStore;
106
+ private worktreeTracker: WorktreeTracker;
107
+ private prPoller: SessionOrchestratorDeps["prPoller"];
108
+ private agentExecutor: AgentExecutor;
109
+
110
+ // Auto-relaunch state
111
+ private relaunchingSet = new Set<string>();
112
+ private autoRelaunchCounts = new Map<string, number>();
113
+ // Sessions that have already been notified about relaunch exhaustion.
114
+ // Prevents repeated "keeps crashing" warnings for dead sessions.
115
+ private relaunchExhaustedNotified = new Set<string>();
116
+
117
+ // Idempotency guard for initialize()
118
+ private _initialized = false;
119
+
120
+ // Event listeners
121
+ private exitCallbacks: ((sessionId: string, exitCode: number | null) => void)[] = [];
122
+
123
+ constructor(deps: SessionOrchestratorDeps) {
124
+ this.launcher = deps.launcher;
125
+ this.wsBridge = deps.wsBridge;
126
+ this.sessionStore = deps.sessionStore;
127
+ this.worktreeTracker = deps.worktreeTracker;
128
+ this.prPoller = deps.prPoller;
129
+ this.agentExecutor = deps.agentExecutor;
130
+ }
131
+
132
+ // ── Initialization (event wiring) ──────────────────────────────────────────
133
+
134
+ initialize(): void {
135
+ if (this._initialized) return;
136
+ this._initialized = true;
137
+
138
+ // When the CLI reports its internal session_id, store it for --resume
139
+ heyHankBus.on("session:cli-id-received", ({ sessionId, cliSessionId }) => {
140
+ this.launcher.setCLISessionId(sessionId, cliSessionId);
141
+ });
142
+
143
+ // When a Codex adapter is created, attach it to the WsBridge
144
+ heyHankBus.on("backend:codex-adapter-created", ({ sessionId, adapter }) => {
145
+ this.wsBridge.attachBackendAdapter(sessionId, adapter, "codex");
146
+ });
147
+
148
+ // When a CLI/Codex process exits, notify agent executor and external listeners
149
+ // separately so a throw in one doesn't skip the other (bus isolates each handler).
150
+ heyHankBus.on("session:exited", ({ sessionId, exitCode }) => {
151
+ this.agentExecutor.handleSessionExited(sessionId, exitCode);
152
+ });
153
+ heyHankBus.on("session:exited", ({ sessionId, exitCode }) => {
154
+ for (const cb of this.exitCallbacks) {
155
+ try {
156
+ cb(sessionId, exitCode);
157
+ } catch (err) {
158
+ console.error("[orchestrator] exitCallback error:", err);
159
+ }
160
+ }
161
+ });
162
+ heyHankBus.on("session:exited", ({ sessionId }) => {
163
+ const session = this.wsBridge.getSession(sessionId);
164
+ if (session?.stateMachine) {
165
+ session.stateMachine.transition("terminated", "process_exited");
166
+ }
167
+ });
168
+
169
+ // Start watching PRs when git info is resolved
170
+ heyHankBus.on("session:git-info-ready", ({ sessionId, cwd, branch }) => {
171
+ this.prPoller.watch(sessionId, cwd, branch);
172
+ });
173
+
174
+ // Auto-relaunch CLI when a browser connects to a session with no CLI
175
+ heyHankBus.on("session:relaunch-needed", async ({ sessionId }) => {
176
+ await this.handleAutoRelaunch(sessionId);
177
+ });
178
+
179
+ // Kill CLI process when idle with no browsers for 24 hours.
180
+ // Only kills the CLI process — containers are preserved so the session
181
+ // can be relaunched without recreating the container.
182
+ heyHankBus.on("session:idle-kill", async ({ sessionId }) => {
183
+ const info = this.launcher.getSession(sessionId);
184
+ if (!info || info.archived) return;
185
+ log.info("orchestrator", "Idle-killing session (preserving container)", { sessionId, reason: "no browsers, no activity" });
186
+ await this.launcher.kill(sessionId);
187
+ // Clear relaunch counters so the session gets a fresh budget when the user
188
+ // returns. Idle-kill is intentional cleanup, not a crash — the session
189
+ // should be fully relaunchable.
190
+ this.clearAutoRelaunchCount(sessionId);
191
+ });
192
+
193
+ // Auto-generate session title after first turn completes
194
+ heyHankBus.on("session:first-turn-completed", async ({ sessionId, firstUserMessage }) => {
195
+ await this.handleAutoNaming(sessionId, firstUserMessage);
196
+ });
197
+
198
+ // Reconnection watchdog for stale sessions after server restart
199
+ this.startReconnectionWatchdog();
200
+ }
201
+
202
+ // ── Session Creation ───────────────────────────────────────────────────────
203
+
204
+ async createSession(body: CreateSessionRequest): Promise<CreateSessionResult> {
205
+ return this.doCreateSession(body);
206
+ }
207
+
208
+ async createSessionStreaming(
209
+ body: CreateSessionRequest,
210
+ onProgress: ProgressCallback,
211
+ ): Promise<CreateSessionResult> {
212
+ return this.doCreateSession(body, onProgress);
213
+ }
214
+
215
+ private async doCreateSession(
216
+ body: CreateSessionRequest,
217
+ onProgress?: ProgressCallback,
218
+ ): Promise<CreateSessionResult> {
219
+ try {
220
+ const resumeSessionAt =
221
+ typeof body.resumeSessionAt === "string" && body.resumeSessionAt.trim()
222
+ ? body.resumeSessionAt.trim()
223
+ : undefined;
224
+ const forkSession = body.forkSession === true;
225
+ const backend = (body.backend ?? "claude") as BackendType;
226
+ if (backend !== "claude" && backend !== "codex") {
227
+ return { ok: false, error: `Invalid backend: ${String(body.backend)}`, status: 400 };
228
+ }
229
+
230
+ // --- Step: Resolve environment ---
231
+ if (onProgress) await onProgress("resolving_env", "Resolving environment...", "in_progress");
232
+
233
+ let envVars: Record<string, string> | undefined = body.env;
234
+ const heyhankEnv = body.envSlug ? envManager.getEnv(body.envSlug) : null;
235
+ if (body.envSlug && heyhankEnv) {
236
+ console.log(
237
+ `[orchestrator] Injecting env "${heyhankEnv.name}" (${Object.keys(heyhankEnv.variables).length} vars):`,
238
+ Object.keys(heyhankEnv.variables).join(", "),
239
+ );
240
+ envVars = { ...heyhankEnv.variables, ...body.env };
241
+ } else if (body.envSlug) {
242
+ console.warn(`[orchestrator] Environment "${body.envSlug}" not found, ignoring`);
243
+ }
244
+
245
+ // Inject provider tokens from global settings (if not already set by env profile).
246
+ // Note: these tokens also flow into containerized sessions intentionally — the
247
+ // global onboarding tokens serve as defaults for all session types, including
248
+ // containers, so that container auth preflight checks pass automatically.
249
+ const globalSettings = getSettings();
250
+ if (backend === "claude" && globalSettings.claudeCodeOAuthToken && !("CLAUDE_CODE_OAUTH_TOKEN" in (envVars ?? {}))) {
251
+ envVars = { ...envVars, CLAUDE_CODE_OAUTH_TOKEN: globalSettings.claudeCodeOAuthToken };
252
+ }
253
+ if (backend === "codex" && globalSettings.openaiApiKey && !("OPENAI_API_KEY" in (envVars ?? {}))) {
254
+ envVars = { ...envVars, OPENAI_API_KEY: globalSettings.openaiApiKey };
255
+ }
256
+
257
+ // Inject provider env vars if a providerId is specified
258
+ if (body.providerId && backend === "claude") {
259
+ const { getProviderEnvVars } = await import("./provider-manager.js");
260
+ const providerEnv = getProviderEnvVars(body.providerId);
261
+ if (providerEnv) {
262
+ // Provider env vars are defaults — env profile overrides them
263
+ envVars = { ...providerEnv, ...envVars };
264
+ }
265
+ }
266
+
267
+ // Resolve sandbox configuration
268
+ const sandboxEnabled = body.sandboxEnabled === true;
269
+ const sandbox = body.sandboxSlug ? sandboxManager.getSandbox(body.sandboxSlug) : null;
270
+ if (sandboxEnabled && body.sandboxSlug && !sandbox) {
271
+ return { ok: false, error: `Sandbox "${body.sandboxSlug}" not found`, status: 404 };
272
+ }
273
+
274
+ // Resolve Docker image early
275
+ let effectiveImage: string | null = null;
276
+ if (sandboxEnabled) {
277
+ effectiveImage = "the-companion:latest";
278
+ } else if (body.container?.image) {
279
+ effectiveImage = body.container.image;
280
+ }
281
+ const isDockerSession = !!effectiveImage;
282
+
283
+ if (onProgress) await onProgress("resolving_env", "Environment resolved", "done");
284
+
285
+ let cwd = body.cwd;
286
+ let worktreeInfo: { isWorktree: boolean; repoRoot: string; branch: string; actualBranch: string; worktreePath: string } | undefined;
287
+
288
+ // Validate branch name to prevent command injection
289
+ if (body.branch && !/^[a-zA-Z0-9/_.\-]+$/.test(body.branch)) {
290
+ return { ok: false, error: "Invalid branch name", status: 400 };
291
+ }
292
+
293
+ // --- Step: Git operations (host only) ---
294
+ if (!isDockerSession && body.useWorktree && body.branch && cwd) {
295
+ const repoInfo = gitUtils.getRepoInfo(cwd);
296
+ if (repoInfo) {
297
+ if (onProgress) await onProgress("fetching_git", "Fetching from remote...", "in_progress");
298
+ const fetchResult = gitUtils.gitFetch(repoInfo.repoRoot);
299
+ if (!fetchResult.success) {
300
+ console.warn(`[orchestrator] git fetch failed (non-fatal): ${fetchResult.output}`);
301
+ }
302
+ if (onProgress) await onProgress("fetching_git", fetchResult.success ? "Fetch complete" : "Fetch skipped (offline?)", "done");
303
+
304
+ if (onProgress) await onProgress("creating_worktree", "Creating worktree...", "in_progress");
305
+ const result = gitUtils.ensureWorktree(repoInfo.repoRoot, body.branch, {
306
+ baseBranch: repoInfo.defaultBranch,
307
+ createBranch: body.createBranch,
308
+ forceNew: true,
309
+ });
310
+ cwd = result.worktreePath;
311
+ worktreeInfo = {
312
+ isWorktree: true,
313
+ repoRoot: repoInfo.repoRoot,
314
+ branch: body.branch,
315
+ actualBranch: result.actualBranch,
316
+ worktreePath: result.worktreePath,
317
+ };
318
+ }
319
+ if (onProgress) await onProgress("creating_worktree", "Worktree ready", "done");
320
+ } else if (!isDockerSession && body.branch && cwd) {
321
+ const repoInfo = gitUtils.getRepoInfo(cwd);
322
+ if (repoInfo) {
323
+ if (onProgress) await onProgress("fetching_git", "Fetching from remote...", "in_progress");
324
+ const fetchResult = gitUtils.gitFetch(repoInfo.repoRoot);
325
+ if (!fetchResult.success) {
326
+ console.warn(`[orchestrator] git fetch failed (non-fatal): ${fetchResult.output}`);
327
+ }
328
+ if (onProgress) await onProgress("fetching_git", fetchResult.success ? "Fetch complete" : "Fetch skipped (offline?)", "done");
329
+
330
+ if (repoInfo.currentBranch !== body.branch) {
331
+ if (onProgress) await onProgress("checkout_branch", `Checking out ${body.branch}...`, "in_progress");
332
+ gitUtils.checkoutOrCreateBranch(repoInfo.repoRoot, body.branch, {
333
+ createBranch: body.createBranch,
334
+ defaultBranch: repoInfo.defaultBranch,
335
+ });
336
+ if (onProgress) await onProgress("checkout_branch", `On branch ${body.branch}`, "done");
337
+ }
338
+
339
+ if (onProgress) await onProgress("pulling_git", "Pulling latest changes...", "in_progress");
340
+ const pullResult = gitUtils.gitPull(repoInfo.repoRoot);
341
+ if (!pullResult.success) {
342
+ console.warn(`[orchestrator] git pull warning (non-fatal): ${pullResult.output}`);
343
+ }
344
+ if (onProgress) await onProgress("pulling_git", "Up to date", "done");
345
+ }
346
+ }
347
+
348
+ let containerInfo: ContainerInfo | undefined;
349
+ let containerId: string | undefined;
350
+ let containerName: string | undefined;
351
+ let containerImage: string | undefined;
352
+
353
+ // Container auth pre-flight check
354
+ if (effectiveImage && backend === "claude" && !hasContainerClaudeAuth(envVars)) {
355
+ return {
356
+ ok: false,
357
+ error: "Containerized Claude requires auth available inside the container. " +
358
+ "Set ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN / CLAUDE_CODE_AUTH_TOKEN) in the selected environment.",
359
+ status: 400,
360
+ };
361
+ }
362
+ if (effectiveImage && backend === "codex" && !hasContainerCodexAuth(envVars)) {
363
+ return {
364
+ ok: false,
365
+ error: "Containerized Codex requires auth available inside the container. " +
366
+ "Set OPENAI_API_KEY in the selected environment, or ensure ~/.codex/auth.json exists on the host.",
367
+ status: 400,
368
+ };
369
+ }
370
+
371
+ // --- Step: Container setup ---
372
+ if (effectiveImage) {
373
+ if (!imagePullManager.isReady(effectiveImage)) {
374
+ const pullState = imagePullManager.getState(effectiveImage);
375
+ if (pullState.status === "idle" || pullState.status === "error") {
376
+ imagePullManager.ensureImage(effectiveImage);
377
+ }
378
+
379
+ if (onProgress) {
380
+ await onProgress("pulling_image", "Pulling Docker image...", "in_progress");
381
+ const unsub = imagePullManager.onProgress(effectiveImage, (line: string) => {
382
+ onProgress("pulling_image", "Pulling Docker image...", "in_progress", line).catch(() => {});
383
+ });
384
+ const ready = await imagePullManager.waitForReady(effectiveImage, 300_000);
385
+ unsub();
386
+ if (ready) {
387
+ await onProgress("pulling_image", "Image ready", "done");
388
+ } else {
389
+ const state = imagePullManager.getState(effectiveImage);
390
+ return {
391
+ ok: false,
392
+ error: state.error || `Docker image ${effectiveImage} could not be pulled or built.`,
393
+ status: 503,
394
+ };
395
+ }
396
+ } else {
397
+ const ready = await imagePullManager.waitForReady(effectiveImage, 300_000);
398
+ if (!ready) {
399
+ const state = imagePullManager.getState(effectiveImage);
400
+ return {
401
+ ok: false,
402
+ error: state.error || `Docker image ${effectiveImage} could not be pulled or built.`,
403
+ status: 503,
404
+ };
405
+ }
406
+ }
407
+ }
408
+
409
+ // Create container
410
+ if (onProgress) await onProgress("creating_container", "Starting container...", "in_progress");
411
+ const tempId = crypto.randomUUID().slice(0, 8);
412
+ const requestedPorts = Array.isArray(body.container?.ports)
413
+ ? body.container!.ports!.map(Number).filter((n: number) => n > 0)
414
+ : [];
415
+ const containerPorts: (number | { port: number; hostIp?: string })[] = [
416
+ ...Array.from(new Set([
417
+ ...requestedPorts.filter((p: number) => p !== NOVNC_CONTAINER_PORT),
418
+ VSCODE_EDITOR_CONTAINER_PORT,
419
+ ...(backend === "codex" ? [CODEX_APP_SERVER_CONTAINER_PORT] : []),
420
+ ])),
421
+ { port: NOVNC_CONTAINER_PORT, hostIp: "127.0.0.1" },
422
+ ];
423
+ const cConfig: ContainerConfig = {
424
+ image: effectiveImage,
425
+ ports: containerPorts,
426
+ volumes: body.container?.volumes,
427
+ env: { ...(envVars ?? {}), DISPLAY: ":99" },
428
+ privileged: sandboxEnabled && effectiveImage === "the-companion:latest",
429
+ };
430
+ try {
431
+ containerInfo = containerManager.createContainer(tempId, cwd!, cConfig);
432
+ } catch (err) {
433
+ const reason = err instanceof Error ? err.message : String(err);
434
+ return {
435
+ ok: false,
436
+ error: `Docker is required to run this environment image (${effectiveImage}) but container startup failed: ${reason}`,
437
+ status: 503,
438
+ };
439
+ }
440
+ containerId = containerInfo.containerId;
441
+ containerName = containerInfo.name;
442
+ containerImage = effectiveImage;
443
+ if (onProgress) await onProgress("creating_container", "Container running", "done");
444
+
445
+ // Copy workspace
446
+ if (onProgress) await onProgress("copying_workspace", "Copying workspace files...", "in_progress");
447
+ try {
448
+ await containerManager.copyWorkspaceToContainer(containerInfo.containerId, cwd!);
449
+ containerManager.reseedGitAuth(containerInfo.containerId);
450
+ if (onProgress) await onProgress("copying_workspace", "Workspace copied", "done");
451
+ } catch (err) {
452
+ containerManager.removeContainer(tempId);
453
+ const reason = err instanceof Error ? err.message : String(err);
454
+ return { ok: false, error: `Failed to copy workspace to container: ${reason}`, status: 503 };
455
+ }
456
+
457
+ // Git operations inside container
458
+ if (body.branch) {
459
+ const repoInfo = cwd ? gitUtils.getRepoInfo(cwd) : null;
460
+ if (onProgress) await onProgress("fetching_git", "Fetching from remote (in container)...", "in_progress");
461
+ const gitResult = containerManager.gitOpsInContainer(containerInfo.containerId, {
462
+ branch: body.branch,
463
+ currentBranch: repoInfo?.currentBranch || "HEAD",
464
+ createBranch: body.createBranch,
465
+ defaultBranch: repoInfo?.defaultBranch,
466
+ });
467
+ if (onProgress) await onProgress("fetching_git", gitResult.fetchOk ? "Fetch complete" : "Fetch skipped", "done");
468
+ if (onProgress && repoInfo?.currentBranch !== body.branch) {
469
+ await onProgress("checkout_branch",
470
+ gitResult.checkoutOk ? `On branch ${body.branch}` : "Checkout failed",
471
+ gitResult.checkoutOk ? "done" : "error",
472
+ );
473
+ }
474
+ if (onProgress) await onProgress("pulling_git", gitResult.pullOk ? "Up to date" : "Pull skipped", "done");
475
+ if (gitResult.errors.length > 0) {
476
+ console.warn(`[orchestrator] In-container git ops warnings: ${gitResult.errors.join("; ")}`);
477
+ }
478
+ if (!gitResult.checkoutOk) {
479
+ containerManager.removeContainer(tempId);
480
+ return {
481
+ ok: false,
482
+ error: `Failed to checkout branch "${body.branch}" inside container: ${gitResult.errors.join("; ")}`,
483
+ status: 400,
484
+ };
485
+ }
486
+ }
487
+
488
+ // Init script
489
+ const initScript = sandbox?.initScript?.trim();
490
+ if (initScript) {
491
+ if (onProgress) await onProgress("running_init_script", "Running init script...", "in_progress");
492
+ try {
493
+ console.log(`[orchestrator] Running init script for sandbox "${sandbox?.name || "sandbox"}" in container ${containerInfo.name}...`);
494
+ const initTimeout = Number(process.env.HEYHANK_INIT_SCRIPT_TIMEOUT || process.env.COMPANION_INIT_SCRIPT_TIMEOUT) || 120_000;
495
+ const result = await containerManager.execInContainerAsync(
496
+ containerInfo.containerId,
497
+ ["sh", "-lc", initScript],
498
+ {
499
+ timeout: initTimeout,
500
+ onOutput: onProgress
501
+ ? (line: string) => { onProgress("running_init_script", "Running init script...", "in_progress", line).catch(() => {}); }
502
+ : undefined,
503
+ },
504
+ );
505
+ if (result.exitCode !== 0) {
506
+ console.error(`[orchestrator] Init script failed (exit ${result.exitCode}):\n${result.output}`);
507
+ containerManager.removeContainer(tempId);
508
+ const truncated = result.output.length > 2000
509
+ ? result.output.slice(0, 500) + "\n...[truncated]...\n" + result.output.slice(-1500)
510
+ : result.output;
511
+ return { ok: false, error: `Init script failed (exit ${result.exitCode}):\n${truncated}`, status: 503 };
512
+ }
513
+ if (onProgress) await onProgress("running_init_script", "Init script complete", "done");
514
+ console.log(`[orchestrator] Init script completed successfully for sandbox "${sandbox?.name || "sandbox"}"`);
515
+ } catch (e) {
516
+ containerManager.removeContainer(tempId);
517
+ const reason = e instanceof Error ? e.message : String(e);
518
+ return { ok: false, error: `Init script execution failed: ${reason}`, status: 503 };
519
+ }
520
+ }
521
+ }
522
+
523
+ // --- Step: Launch CLI ---
524
+ if (onProgress) await onProgress("launching_cli", `Launching ${backend === "codex" ? "Codex" : "Claude Code"}...`, "in_progress");
525
+
526
+ let session: SdkSessionInfo;
527
+ try {
528
+ session = this.launcher.launch({
529
+ model: body.model,
530
+ permissionMode: body.permissionMode,
531
+ cwd,
532
+ claudeBinary: body.claudeBinary,
533
+ codexBinary: body.codexBinary,
534
+ codexInternetAccess: backend === "codex",
535
+ codexSandbox: backend === "codex" ? "danger-full-access" : undefined,
536
+ allowedTools: body.allowedTools,
537
+ env: envVars,
538
+ backendType: backend,
539
+ containerId,
540
+ containerName,
541
+ containerImage,
542
+ containerCwd: containerInfo?.containerCwd,
543
+ resumeSessionAt,
544
+ forkSession,
545
+ systemPrompt: undefined,
546
+ sandboxSlug: sandboxEnabled ? (body.sandboxSlug || undefined) : undefined,
547
+ provider: body.providerId,
548
+ });
549
+ } catch (e) {
550
+ // Clean up container if it was created but launch failed
551
+ if (containerId) containerManager.removeContainer(containerId);
552
+ const reason = e instanceof Error ? e.message : String(e);
553
+ return { ok: false, error: `Failed to launch CLI: ${reason}`, status: 503 };
554
+ }
555
+
556
+ // Post-launch wiring
557
+ if (containerInfo) {
558
+ containerManager.retrack(containerInfo.containerId, session.sessionId);
559
+ this.wsBridge.markContainerized(session.sessionId, cwd!);
560
+ }
561
+
562
+ if (worktreeInfo) {
563
+ this.worktreeTracker.addMapping({
564
+ sessionId: session.sessionId,
565
+ repoRoot: worktreeInfo.repoRoot,
566
+ branch: worktreeInfo.branch,
567
+ actualBranch: worktreeInfo.actualBranch,
568
+ worktreePath: worktreeInfo.worktreePath,
569
+ createdAt: Date.now(),
570
+ });
571
+ }
572
+
573
+ const discovered = await discoverCommandsAndSkills(cwd).catch(() => ({ slash_commands: [] as string[], skills: [] as string[] }));
574
+ this.wsBridge.prePopulateCommands(session.sessionId, discovered.slash_commands, discovered.skills);
575
+
576
+ if (onProgress) await onProgress("launching_cli", "Session started", "done");
577
+
578
+ metricsCollector.recordSessionCreated(backend);
579
+ metricsCollector.recordSessionSpawned(session.sessionId);
580
+
581
+ return { ok: true, session };
582
+ } catch (e: unknown) {
583
+ const msg = e instanceof Error ? e.message : String(e);
584
+ log.error("orchestrator", "Failed to create session", { error: msg });
585
+ return { ok: false, error: msg, status: 500 };
586
+ }
587
+ }
588
+
589
+ // ── Kill ───────────────────────────────────────────────────────────────────
590
+
591
+ async killSession(sessionId: string): Promise<{ ok: boolean }> {
592
+ const killed = await this.launcher.kill(sessionId);
593
+ if (killed) {
594
+ containerManager.removeContainer(sessionId);
595
+ }
596
+ return { ok: killed };
597
+ }
598
+
599
+ // ── Relaunch ───────────────────────────────────────────────────────────────
600
+
601
+ async relaunchSession(sessionId: string): Promise<{ ok: boolean; error?: string }> {
602
+ const info = this.launcher.getSession(sessionId);
603
+ if (info?.archived) {
604
+ return { ok: false, error: "Session is archived and cannot be relaunched" };
605
+ }
606
+ this.clearAutoRelaunchCount(sessionId);
607
+ const session = this.wsBridge.getSession(sessionId);
608
+ if (session?.stateMachine) {
609
+ session.stateMachine.transition("starting", "relaunch_initiated");
610
+ }
611
+ return this.launcher.relaunch(sessionId);
612
+ }
613
+
614
+ // ── Archive ────────────────────────────────────────────────────────────────
615
+
616
+ async archiveSession(sessionId: string, options?: ArchiveSessionOptions): Promise<ArchiveSessionResult> {
617
+ await this.launcher.kill(sessionId);
618
+ containerManager.removeContainer(sessionId);
619
+ this.prPoller.unwatch(sessionId);
620
+
621
+ const worktreeResult = this.cleanupWorktree(sessionId, options?.force);
622
+ this.launcher.setArchived(sessionId, true);
623
+ this.sessionStore.setArchived(sessionId, true);
624
+
625
+ return { ok: true, worktree: worktreeResult };
626
+ }
627
+
628
+ // ── Delete ─────────────────────────────────────────────────────────────────
629
+
630
+ async deleteSession(sessionId: string): Promise<DeleteSessionResult> {
631
+ await this.launcher.kill(sessionId);
632
+ containerManager.removeContainer(sessionId);
633
+ const worktreeResult = this.cleanupWorktree(sessionId, true);
634
+ this.prPoller.unwatch(sessionId);
635
+ this.launcher.removeSession(sessionId);
636
+ this.wsBridge.closeSession(sessionId);
637
+ this.autoRelaunchCounts.delete(sessionId);
638
+ this.relaunchExhaustedNotified.delete(sessionId);
639
+ this.relaunchingSet.delete(sessionId);
640
+ return { ok: true, worktree: worktreeResult };
641
+ }
642
+
643
+ // ── Unarchive ──────────────────────────────────────────────────────────────
644
+
645
+ unarchiveSession(sessionId: string): { ok: boolean } {
646
+ this.launcher.setArchived(sessionId, false);
647
+ this.sessionStore.setArchived(sessionId, false);
648
+ return { ok: true };
649
+ }
650
+
651
+ // ── Auto-relaunch count ────────────────────────────────────────────────────
652
+
653
+ clearAutoRelaunchCount(sessionId: string): void {
654
+ this.autoRelaunchCounts.delete(sessionId);
655
+ this.relaunchExhaustedNotified.delete(sessionId);
656
+ }
657
+
658
+ // ── Event registration ─────────────────────────────────────────────────────
659
+
660
+ /** Register a callback for session exit events. Returns unsubscribe function. */
661
+ onSessionExited(cb: (sessionId: string, exitCode: number | null) => void): () => void {
662
+ this.exitCallbacks.push(cb);
663
+ return () => {
664
+ const idx = this.exitCallbacks.indexOf(cb);
665
+ if (idx !== -1) this.exitCallbacks.splice(idx, 1);
666
+ };
667
+ }
668
+
669
+ // ── Query delegation ───────────────────────────────────────────────────────
670
+
671
+ getSession(sessionId: string): SdkSessionInfo | undefined {
672
+ return this.launcher.getSession(sessionId);
673
+ }
674
+
675
+ // ── Cleanup ────────────────────────────────────────────────────────────────
676
+
677
+ shutdown(): void {
678
+ // Timers are owned by the process lifecycle
679
+ }
680
+
681
+ // ── Private: Auto-relaunch ─────────────────────────────────────────────────
682
+
683
+ private async handleAutoRelaunch(sessionId: string): Promise<void> {
684
+ if (this.relaunchingSet.has(sessionId)) return;
685
+ const info = this.launcher.getSession(sessionId);
686
+ if (info?.archived) return;
687
+
688
+ // If we've already notified the user about relaunch exhaustion, bail out
689
+ // silently. Without this, every reconnect event from a dead session
690
+ // (e.g. deleted container) re-logs the "limit reached" warning endlessly.
691
+ if (this.relaunchExhaustedNotified.has(sessionId)) return;
692
+
693
+ this.relaunchingSet.add(sessionId);
694
+
695
+ await new Promise((r) => setTimeout(r, RELAUNCH_GRACE_MS));
696
+ if (this.wsBridge.isCliConnected(sessionId)) { this.relaunchingSet.delete(sessionId); return; }
697
+ const freshInfo = this.launcher.getSession(sessionId);
698
+ if (freshInfo && (freshInfo.state === "connected" || freshInfo.state === "running")) {
699
+ this.relaunchingSet.delete(sessionId); return;
700
+ }
701
+ // Only check PID liveness if the session is NOT already "exited".
702
+ // After idle-kill or explicit kill(), the PID field stays set but the
703
+ // process is dead. If the kernel recycles the PID to a different process,
704
+ // kill(pid, 0) would incorrectly succeed, preventing any relaunch.
705
+ // For containerized sessions, use container liveness instead of PID check
706
+ // (the PID is the `docker exec` wrapper, which exits immediately for some
707
+ // transports and is unreliable for container health).
708
+ if (freshInfo && freshInfo.state !== "exited") {
709
+ if (freshInfo.containerId) {
710
+ const containerState = containerManager.isContainerAlive(freshInfo.containerId);
711
+ if (containerState === "running") {
712
+ this.relaunchingSet.delete(sessionId);
713
+ return;
714
+ }
715
+ } else if (freshInfo.pid) {
716
+ try { process.kill(freshInfo.pid, 0); this.relaunchingSet.delete(sessionId); return; } catch {}
717
+ }
718
+ }
719
+
720
+ const count = this.autoRelaunchCounts.get(sessionId) ?? 0;
721
+ if (count >= MAX_AUTO_RELAUNCHES) {
722
+ metricsCollector.recordRelaunchExhausted();
723
+ log.warn("orchestrator", "Auto-relaunch limit reached", { sessionId, maxAttempts: MAX_AUTO_RELAUNCHES });
724
+ this.wsBridge.broadcastToSession(sessionId, {
725
+ type: "error",
726
+ message: "Session keeps crashing. Please relaunch manually.",
727
+ });
728
+ this.relaunchExhaustedNotified.add(sessionId);
729
+ this.relaunchingSet.delete(sessionId);
730
+ return;
731
+ }
732
+
733
+ if (freshInfo && freshInfo.state !== "starting") {
734
+ this.autoRelaunchCounts.set(sessionId, count + 1);
735
+ metricsCollector.recordRelaunchAttempted();
736
+ log.info("orchestrator", "Auto-relaunching CLI", { sessionId, attempt: count + 1, maxAttempts: MAX_AUTO_RELAUNCHES });
737
+ const session = this.wsBridge.getSession(sessionId);
738
+ if (session?.stateMachine) {
739
+ session.stateMachine.transition("starting", "relaunch_initiated");
740
+ }
741
+ try {
742
+ const result = await this.launcher.relaunch(sessionId);
743
+ if (!result.ok && result.error) {
744
+ this.wsBridge.broadcastToSession(sessionId, { type: "error", message: result.error });
745
+ } else if (result.ok) {
746
+ metricsCollector.recordRelaunchSucceeded();
747
+ this.autoRelaunchCounts.delete(sessionId);
748
+ this.relaunchExhaustedNotified.delete(sessionId);
749
+ }
750
+ // ok=false without error: keep count to preserve the retry budget
751
+ } finally {
752
+ setTimeout(() => this.relaunchingSet.delete(sessionId), RELAUNCH_COOLDOWN_MS);
753
+ }
754
+ } else {
755
+ this.relaunchingSet.delete(sessionId);
756
+ }
757
+ }
758
+
759
+ // ── Private: Auto-naming ───────────────────────────────────────────────────
760
+
761
+ private async handleAutoNaming(sessionId: string, firstUserMessage: string): Promise<void> {
762
+ if (sessionNames.getName(sessionId)) return;
763
+ if (!getSettings().anthropicApiKey.trim()) return;
764
+ const info = this.launcher.getSession(sessionId);
765
+ const model = info?.model || "claude-sonnet-4-6";
766
+ console.log(`[orchestrator] Auto-naming session ${sessionId} via Anthropic with model ${model}...`);
767
+ const title = await generateSessionTitle(firstUserMessage, model);
768
+ if (title && !sessionNames.getName(sessionId)) {
769
+ console.log(`[orchestrator] Auto-named session ${sessionId}: "${title}"`);
770
+ sessionNames.setName(sessionId, title);
771
+ this.wsBridge.broadcastNameUpdate(sessionId, title);
772
+ }
773
+ }
774
+
775
+ // ── Private: Reconnection watchdog ─────────────────────────────────────────
776
+
777
+ private startReconnectionWatchdog(): void {
778
+ const starting = this.launcher.getStartingSessions();
779
+ if (starting.length > 0) {
780
+ console.log(`[orchestrator] Waiting ${RECONNECT_GRACE_MS / 1000}s for ${starting.length} CLI process(es) to reconnect...`);
781
+ setTimeout(async () => {
782
+ const stale = this.launcher.getStartingSessions();
783
+ for (const info of stale) {
784
+ if (info.archived) continue;
785
+ console.log(`[orchestrator] CLI for session ${info.sessionId} did not reconnect, relaunching...`);
786
+ await this.launcher.relaunch(info.sessionId);
787
+ }
788
+ }, RECONNECT_GRACE_MS);
789
+ }
790
+ }
791
+
792
+ // ── Private: Worktree cleanup ──────────────────────────────────────────────
793
+
794
+ private cleanupWorktree(
795
+ sessionId: string,
796
+ force?: boolean,
797
+ ): { cleaned?: boolean; dirty?: boolean; path?: string } | undefined {
798
+ const mapping = this.worktreeTracker.getBySession(sessionId);
799
+ if (!mapping) return undefined;
800
+
801
+ if (this.worktreeTracker.isWorktreeInUse(mapping.worktreePath, sessionId)) {
802
+ this.worktreeTracker.removeBySession(sessionId);
803
+ return { cleaned: false, path: mapping.worktreePath };
804
+ }
805
+
806
+ const dirty = gitUtils.isWorktreeDirty(mapping.worktreePath);
807
+ if (dirty && !force) {
808
+ return { cleaned: false, dirty: true, path: mapping.worktreePath };
809
+ }
810
+
811
+ const branchToDelete =
812
+ mapping.actualBranch && mapping.actualBranch !== mapping.branch
813
+ ? mapping.actualBranch
814
+ : undefined;
815
+ const result = gitUtils.removeWorktree(mapping.repoRoot, mapping.worktreePath, {
816
+ force: dirty,
817
+ branchToDelete,
818
+ });
819
+ if (result.removed) {
820
+ this.worktreeTracker.removeBySession(sessionId);
821
+ }
822
+ return { cleaned: result.removed, path: mapping.worktreePath };
823
+ }
824
+ }