@hellcoder/companion 0.96.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 (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,973 @@
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 * as sessionLinearIssues from "./session-linear-issues.js";
15
+ import { getConnection, resolveApiKey } from "./linear-connections.js";
16
+ import { buildLinearSystemPrompt } from "./linear-prompt-builder.js";
17
+ import { transitionLinearIssue, fetchLinearTeamStates } from "./routes/linear-routes.js";
18
+ import { hasContainerClaudeAuth } from "./claude-container-auth.js";
19
+ import { hasContainerCodexAuth } from "./codex-container-auth.js";
20
+ import { discoverCommandsAndSkills } from "./commands-discovery.js";
21
+ import { getSettings } from "./settings-manager.js";
22
+ import { generateSessionTitle } from "./auto-namer.js";
23
+ import { companionBus } from "./event-bus.js";
24
+ import { metricsCollector } from "./metrics-collector.js";
25
+ import { log } from "./logger.js";
26
+
27
+ // ── Constants ────────────────────────────────────────────────────────────────
28
+
29
+ const MAX_AUTO_RELAUNCHES = 3;
30
+ const RELAUNCH_GRACE_MS = 10_000;
31
+ const RELAUNCH_COOLDOWN_MS = 5_000;
32
+ const RECONNECT_GRACE_MS = Number(process.env.COMPANION_RECONNECT_GRACE_MS || "30000");
33
+
34
+ // Proactive keepalive: base delay before relaunching a crashed CLI (doubles per attempt)
35
+ const KEEPALIVE_BASE_DELAY_MS = 3_000;
36
+
37
+ const VSCODE_EDITOR_CONTAINER_PORT = 13337;
38
+ const CODEX_APP_SERVER_CONTAINER_PORT = Number(
39
+ process.env.COMPANION_CODEX_CONTAINER_WS_PORT || "4502",
40
+ );
41
+ const NOVNC_CONTAINER_PORT = 6080;
42
+
43
+ // ── Types ────────────────────────────────────────────────────────────────────
44
+
45
+ export interface SessionOrchestratorDeps {
46
+ launcher: CliLauncher;
47
+ wsBridge: WsBridge;
48
+ sessionStore: SessionStore;
49
+ worktreeTracker: WorktreeTracker;
50
+ prPoller: {
51
+ watch(sessionId: string, cwd: string, branch: string): void;
52
+ unwatch(sessionId: string): void;
53
+ };
54
+ agentExecutor: AgentExecutor;
55
+ }
56
+
57
+ export interface CreateSessionRequest {
58
+ backend?: string;
59
+ model?: string;
60
+ permissionMode?: string;
61
+ cwd?: string;
62
+ claudeBinary?: string;
63
+ codexBinary?: string;
64
+ allowedTools?: string[];
65
+ env?: Record<string, string>;
66
+ envSlug?: string;
67
+ sandboxEnabled?: boolean;
68
+ sandboxSlug?: string;
69
+ linearConnectionId?: string;
70
+ linearIssue?: unknown;
71
+ branch?: string;
72
+ createBranch?: boolean;
73
+ useWorktree?: boolean;
74
+ container?: { image?: string; ports?: number[]; volumes?: string[] };
75
+ resumeSessionAt?: string;
76
+ forkSession?: boolean;
77
+ }
78
+
79
+ export type CreateSessionResult =
80
+ | { ok: true; session: SdkSessionInfo }
81
+ | { ok: false; error: string; status: number };
82
+
83
+ export type ProgressCallback = (
84
+ step: CreationStepId,
85
+ label: string,
86
+ status: "in_progress" | "done" | "error",
87
+ detail?: string,
88
+ ) => Promise<void>;
89
+
90
+ export interface ArchiveSessionOptions {
91
+ force?: boolean;
92
+ linearTransition?: string;
93
+ }
94
+
95
+ export interface ArchiveSessionResult {
96
+ ok: boolean;
97
+ worktree?: { cleaned?: boolean; dirty?: boolean; path?: string };
98
+ linearTransition?: {
99
+ ok: boolean;
100
+ skipped?: boolean;
101
+ error?: string;
102
+ issue?: { id: string; identifier: string; stateName: string; stateType: string };
103
+ };
104
+ }
105
+
106
+ export interface DeleteSessionResult {
107
+ ok: boolean;
108
+ worktree?: { cleaned?: boolean; dirty?: boolean; path?: string };
109
+ }
110
+
111
+ // ── Orchestrator ─────────────────────────────────────────────────────────────
112
+
113
+ /**
114
+ * Single entry point for session lifecycle operations: create, resume,
115
+ * reconnect, and terminate. Coordinates between CliLauncher (process
116
+ * management), WsBridge (message routing), and SessionStore (persistence).
117
+ */
118
+ export class SessionOrchestrator {
119
+ private launcher: CliLauncher;
120
+ private wsBridge: WsBridge;
121
+ private sessionStore: SessionStore;
122
+ private worktreeTracker: WorktreeTracker;
123
+ private prPoller: SessionOrchestratorDeps["prPoller"];
124
+ private agentExecutor: AgentExecutor;
125
+
126
+ // Auto-relaunch state
127
+ private relaunchingSet = new Set<string>();
128
+ private autoRelaunchCounts = new Map<string, number>();
129
+ // Sessions that have already been notified about relaunch exhaustion.
130
+ // Prevents repeated "keeps crashing" warnings for dead sessions.
131
+ private relaunchExhaustedNotified = new Set<string>();
132
+
133
+ // Tracks sessions intentionally killed (idle-kill, manual delete/archive)
134
+ // so the proactive keepalive doesn't relaunch them.
135
+ private intentionalKills = new Set<string>();
136
+ // Timers for proactive keepalive relaunches (for cancellation on delete)
137
+ private keepaliveTimers = new Map<string, ReturnType<typeof setTimeout>>();
138
+
139
+ // Idempotency guard for initialize()
140
+ private _initialized = false;
141
+
142
+ // Event listeners
143
+ private exitCallbacks: ((sessionId: string, exitCode: number | null) => void)[] = [];
144
+
145
+ constructor(deps: SessionOrchestratorDeps) {
146
+ this.launcher = deps.launcher;
147
+ this.wsBridge = deps.wsBridge;
148
+ this.sessionStore = deps.sessionStore;
149
+ this.worktreeTracker = deps.worktreeTracker;
150
+ this.prPoller = deps.prPoller;
151
+ this.agentExecutor = deps.agentExecutor;
152
+ }
153
+
154
+ // ── Initialization (event wiring) ──────────────────────────────────────────
155
+
156
+ initialize(): void {
157
+ if (this._initialized) return;
158
+ this._initialized = true;
159
+
160
+ // When the CLI reports its internal session_id, store it for --resume
161
+ companionBus.on("session:cli-id-received", ({ sessionId, cliSessionId }) => {
162
+ this.launcher.setCLISessionId(sessionId, cliSessionId);
163
+ });
164
+
165
+ // When a Codex adapter is created, attach it to the WsBridge
166
+ companionBus.on("backend:codex-adapter-created", ({ sessionId, adapter }) => {
167
+ this.wsBridge.attachBackendAdapter(sessionId, adapter, "codex");
168
+ });
169
+
170
+ // When a CLI/Codex process exits, notify agent executor and external listeners
171
+ // separately so a throw in one doesn't skip the other (bus isolates each handler).
172
+ companionBus.on("session:exited", ({ sessionId, exitCode }) => {
173
+ this.agentExecutor.handleSessionExited(sessionId, exitCode);
174
+ });
175
+ companionBus.on("session:exited", ({ sessionId, exitCode }) => {
176
+ for (const cb of this.exitCallbacks) {
177
+ try {
178
+ cb(sessionId, exitCode);
179
+ } catch (err) {
180
+ console.error("[orchestrator] exitCallback error:", err);
181
+ }
182
+ }
183
+ });
184
+ companionBus.on("session:exited", ({ sessionId }) => {
185
+ const session = this.wsBridge.getSession(sessionId);
186
+ if (session?.stateMachine) {
187
+ session.stateMachine.transition("terminated", "process_exited");
188
+ }
189
+ });
190
+
191
+ // Proactive keepalive: auto-relaunch crashed CLI processes even without
192
+ // a browser connected. This ensures long-running sessions (agents, cron
193
+ // jobs) stay alive. Intentional kills (idle-kill, manual delete/archive)
194
+ // are excluded via the intentionalKills set.
195
+ companionBus.on("session:exited", ({ sessionId }) => {
196
+ this.scheduleProactiveRelaunch(sessionId);
197
+ });
198
+
199
+ // Start watching PRs when git info is resolved
200
+ companionBus.on("session:git-info-ready", ({ sessionId, cwd, branch }) => {
201
+ this.prPoller.watch(sessionId, cwd, branch);
202
+ });
203
+
204
+ // Auto-relaunch CLI when a browser connects to a session with no CLI
205
+ companionBus.on("session:relaunch-needed", async ({ sessionId }) => {
206
+ await this.handleAutoRelaunch(sessionId);
207
+ });
208
+
209
+ // Kill CLI process when idle with no browsers for 24 hours.
210
+ // Only kills the CLI process — containers are preserved so the session
211
+ // can be relaunched without recreating the container.
212
+ companionBus.on("session:idle-kill", async ({ sessionId }) => {
213
+ const info = this.launcher.getSession(sessionId);
214
+ if (!info || info.archived) return;
215
+ log.info("orchestrator", "Idle-killing session (preserving container)", { sessionId, reason: "no browsers, no activity" });
216
+ this.intentionalKills.add(sessionId);
217
+ // Cancel the CLI disconnect debounce timer so it doesn't fire
218
+ // session:relaunch-needed after we intentionally kill the process.
219
+ this.wsBridge.cancelDisconnectTimer(sessionId);
220
+ await this.launcher.kill(sessionId);
221
+ // Clear relaunch counters so the session gets a fresh budget when the user
222
+ // returns. Idle-kill is intentional cleanup, not a crash — the session
223
+ // should be fully relaunchable.
224
+ this.clearAutoRelaunchCount(sessionId);
225
+ });
226
+
227
+ // Auto-generate session title after first turn completes
228
+ companionBus.on("session:first-turn-completed", async ({ sessionId, firstUserMessage }) => {
229
+ await this.handleAutoNaming(sessionId, firstUserMessage);
230
+ });
231
+
232
+ // Reconnection watchdog for stale sessions after server restart
233
+ this.startReconnectionWatchdog();
234
+ }
235
+
236
+ // ── Session Creation ───────────────────────────────────────────────────────
237
+
238
+ async createSession(body: CreateSessionRequest): Promise<CreateSessionResult> {
239
+ return this.doCreateSession(body);
240
+ }
241
+
242
+ async createSessionStreaming(
243
+ body: CreateSessionRequest,
244
+ onProgress: ProgressCallback,
245
+ ): Promise<CreateSessionResult> {
246
+ return this.doCreateSession(body, onProgress);
247
+ }
248
+
249
+ private async doCreateSession(
250
+ body: CreateSessionRequest,
251
+ onProgress?: ProgressCallback,
252
+ ): Promise<CreateSessionResult> {
253
+ try {
254
+ const resumeSessionAt =
255
+ typeof body.resumeSessionAt === "string" && body.resumeSessionAt.trim()
256
+ ? body.resumeSessionAt.trim()
257
+ : undefined;
258
+ const forkSession = body.forkSession === true;
259
+ const backend = (body.backend ?? "claude") as BackendType;
260
+ if (backend !== "claude" && backend !== "codex") {
261
+ return { ok: false, error: `Invalid backend: ${String(body.backend)}`, status: 400 };
262
+ }
263
+
264
+ // --- Step: Resolve environment ---
265
+ if (onProgress) await onProgress("resolving_env", "Resolving environment...", "in_progress");
266
+
267
+ let envVars: Record<string, string> | undefined = body.env;
268
+ const companionEnv = body.envSlug ? envManager.getEnv(body.envSlug) : null;
269
+ if (body.envSlug && companionEnv) {
270
+ console.log(
271
+ `[orchestrator] Injecting env "${companionEnv.name}" (${Object.keys(companionEnv.variables).length} vars):`,
272
+ Object.keys(companionEnv.variables).join(", "),
273
+ );
274
+ envVars = { ...companionEnv.variables, ...body.env };
275
+ } else if (body.envSlug) {
276
+ console.warn(`[orchestrator] Environment "${body.envSlug}" not found, ignoring`);
277
+ }
278
+
279
+ // Inject provider tokens from global settings (if not already set by env profile).
280
+ // Note: these tokens also flow into containerized sessions intentionally — the
281
+ // global onboarding tokens serve as defaults for all session types, including
282
+ // containers, so that container auth preflight checks pass automatically.
283
+ const globalSettings = getSettings();
284
+ if (backend === "claude" && globalSettings.claudeCodeOAuthToken && !("CLAUDE_CODE_OAUTH_TOKEN" in (envVars ?? {}))) {
285
+ envVars = { ...envVars, CLAUDE_CODE_OAUTH_TOKEN: globalSettings.claudeCodeOAuthToken };
286
+ }
287
+ if (backend === "codex" && globalSettings.openaiApiKey && !("OPENAI_API_KEY" in (envVars ?? {}))) {
288
+ envVars = { ...envVars, OPENAI_API_KEY: globalSettings.openaiApiKey };
289
+ }
290
+
291
+ // Resolve sandbox configuration
292
+ const sandboxEnabled = body.sandboxEnabled === true;
293
+ const companionSandbox = body.sandboxSlug ? sandboxManager.getSandbox(body.sandboxSlug) : null;
294
+ if (sandboxEnabled && body.sandboxSlug && !companionSandbox) {
295
+ return { ok: false, error: `Sandbox "${body.sandboxSlug}" not found`, status: 404 };
296
+ }
297
+
298
+ // Inject LINEAR_API_KEY if a Linear connection is specified
299
+ let linearSystemPrompt: string | undefined;
300
+ if (body.linearConnectionId) {
301
+ const conn = getConnection(body.linearConnectionId);
302
+ if (conn?.apiKey) {
303
+ envVars = { ...envVars, LINEAR_API_KEY: conn.apiKey };
304
+ linearSystemPrompt = buildLinearSystemPrompt(conn, body.linearIssue as { identifier: string; title: string; stateName: string; teamName: string; url: string } | undefined);
305
+ }
306
+ }
307
+
308
+ // Resolve Docker image early
309
+ let effectiveImage: string | null = null;
310
+ if (sandboxEnabled) {
311
+ effectiveImage = "the-companion:latest";
312
+ } else if (body.container?.image) {
313
+ effectiveImage = body.container.image;
314
+ }
315
+ const isDockerSession = !!effectiveImage;
316
+
317
+ if (onProgress) await onProgress("resolving_env", "Environment resolved", "done");
318
+
319
+ let cwd = body.cwd;
320
+ let worktreeInfo: { isWorktree: boolean; repoRoot: string; branch: string; actualBranch: string; worktreePath: string } | undefined;
321
+
322
+ // Validate branch name to prevent command injection
323
+ if (body.branch && !/^[a-zA-Z0-9/_.\-]+$/.test(body.branch)) {
324
+ return { ok: false, error: "Invalid branch name", status: 400 };
325
+ }
326
+
327
+ // --- Step: Git operations (host only) ---
328
+ if (!isDockerSession && body.useWorktree && body.branch && cwd) {
329
+ const repoInfo = gitUtils.getRepoInfo(cwd);
330
+ if (repoInfo) {
331
+ if (onProgress) await onProgress("fetching_git", "Fetching from remote...", "in_progress");
332
+ const fetchResult = gitUtils.gitFetch(repoInfo.repoRoot);
333
+ if (!fetchResult.success) {
334
+ console.warn(`[orchestrator] git fetch failed (non-fatal): ${fetchResult.output}`);
335
+ }
336
+ if (onProgress) await onProgress("fetching_git", fetchResult.success ? "Fetch complete" : "Fetch skipped (offline?)", "done");
337
+
338
+ if (onProgress) await onProgress("creating_worktree", "Creating worktree...", "in_progress");
339
+ const result = gitUtils.ensureWorktree(repoInfo.repoRoot, body.branch, {
340
+ baseBranch: repoInfo.defaultBranch,
341
+ createBranch: body.createBranch,
342
+ forceNew: true,
343
+ });
344
+ cwd = result.worktreePath;
345
+ worktreeInfo = {
346
+ isWorktree: true,
347
+ repoRoot: repoInfo.repoRoot,
348
+ branch: body.branch,
349
+ actualBranch: result.actualBranch,
350
+ worktreePath: result.worktreePath,
351
+ };
352
+ }
353
+ if (onProgress) await onProgress("creating_worktree", "Worktree ready", "done");
354
+ } else if (!isDockerSession && body.branch && cwd) {
355
+ const repoInfo = gitUtils.getRepoInfo(cwd);
356
+ if (repoInfo) {
357
+ if (onProgress) await onProgress("fetching_git", "Fetching from remote...", "in_progress");
358
+ const fetchResult = gitUtils.gitFetch(repoInfo.repoRoot);
359
+ if (!fetchResult.success) {
360
+ console.warn(`[orchestrator] git fetch failed (non-fatal): ${fetchResult.output}`);
361
+ }
362
+ if (onProgress) await onProgress("fetching_git", fetchResult.success ? "Fetch complete" : "Fetch skipped (offline?)", "done");
363
+
364
+ if (repoInfo.currentBranch !== body.branch) {
365
+ if (onProgress) await onProgress("checkout_branch", `Checking out ${body.branch}...`, "in_progress");
366
+ gitUtils.checkoutOrCreateBranch(repoInfo.repoRoot, body.branch, {
367
+ createBranch: body.createBranch,
368
+ defaultBranch: repoInfo.defaultBranch,
369
+ });
370
+ if (onProgress) await onProgress("checkout_branch", `On branch ${body.branch}`, "done");
371
+ }
372
+
373
+ if (onProgress) await onProgress("pulling_git", "Pulling latest changes...", "in_progress");
374
+ const pullResult = gitUtils.gitPull(repoInfo.repoRoot);
375
+ if (!pullResult.success) {
376
+ console.warn(`[orchestrator] git pull warning (non-fatal): ${pullResult.output}`);
377
+ }
378
+ if (onProgress) await onProgress("pulling_git", "Up to date", "done");
379
+ }
380
+ }
381
+
382
+ let containerInfo: ContainerInfo | undefined;
383
+ let containerId: string | undefined;
384
+ let containerName: string | undefined;
385
+ let containerImage: string | undefined;
386
+
387
+ // Container auth pre-flight check
388
+ if (effectiveImage && backend === "claude" && !hasContainerClaudeAuth(envVars)) {
389
+ return {
390
+ ok: false,
391
+ error: "Containerized Claude requires auth available inside the container. " +
392
+ "Set ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN / CLAUDE_CODE_AUTH_TOKEN) in the selected environment.",
393
+ status: 400,
394
+ };
395
+ }
396
+ if (effectiveImage && backend === "codex" && !hasContainerCodexAuth(envVars)) {
397
+ return {
398
+ ok: false,
399
+ error: "Containerized Codex requires auth available inside the container. " +
400
+ "Set OPENAI_API_KEY in the selected environment, or ensure ~/.codex/auth.json exists on the host.",
401
+ status: 400,
402
+ };
403
+ }
404
+
405
+ // --- Step: Container setup ---
406
+ if (effectiveImage) {
407
+ if (!imagePullManager.isReady(effectiveImage)) {
408
+ const pullState = imagePullManager.getState(effectiveImage);
409
+ if (pullState.status === "idle" || pullState.status === "error") {
410
+ imagePullManager.ensureImage(effectiveImage);
411
+ }
412
+
413
+ if (onProgress) {
414
+ await onProgress("pulling_image", "Pulling Docker image...", "in_progress");
415
+ const unsub = imagePullManager.onProgress(effectiveImage, (line: string) => {
416
+ onProgress("pulling_image", "Pulling Docker image...", "in_progress", line).catch(() => {});
417
+ });
418
+ const ready = await imagePullManager.waitForReady(effectiveImage, 300_000);
419
+ unsub();
420
+ if (ready) {
421
+ await onProgress("pulling_image", "Image ready", "done");
422
+ } else {
423
+ const state = imagePullManager.getState(effectiveImage);
424
+ return {
425
+ ok: false,
426
+ error: state.error || `Docker image ${effectiveImage} could not be pulled or built.`,
427
+ status: 503,
428
+ };
429
+ }
430
+ } else {
431
+ const ready = await imagePullManager.waitForReady(effectiveImage, 300_000);
432
+ if (!ready) {
433
+ const state = imagePullManager.getState(effectiveImage);
434
+ return {
435
+ ok: false,
436
+ error: state.error || `Docker image ${effectiveImage} could not be pulled or built.`,
437
+ status: 503,
438
+ };
439
+ }
440
+ }
441
+ }
442
+
443
+ // Create container
444
+ if (onProgress) await onProgress("creating_container", "Starting container...", "in_progress");
445
+ const tempId = crypto.randomUUID().slice(0, 8);
446
+ const requestedPorts = Array.isArray(body.container?.ports)
447
+ ? body.container!.ports!.map(Number).filter((n: number) => n > 0)
448
+ : [];
449
+ const containerPorts: (number | { port: number; hostIp?: string })[] = [
450
+ ...Array.from(new Set([
451
+ ...requestedPorts.filter((p: number) => p !== NOVNC_CONTAINER_PORT),
452
+ VSCODE_EDITOR_CONTAINER_PORT,
453
+ ...(backend === "codex" ? [CODEX_APP_SERVER_CONTAINER_PORT] : []),
454
+ ])),
455
+ { port: NOVNC_CONTAINER_PORT, hostIp: "127.0.0.1" },
456
+ ];
457
+ const cConfig: ContainerConfig = {
458
+ image: effectiveImage,
459
+ ports: containerPorts,
460
+ volumes: body.container?.volumes,
461
+ env: { ...(envVars ?? {}), DISPLAY: ":99" },
462
+ privileged: sandboxEnabled && effectiveImage === "the-companion:latest",
463
+ };
464
+ try {
465
+ containerInfo = containerManager.createContainer(tempId, cwd!, cConfig);
466
+ } catch (err) {
467
+ const reason = err instanceof Error ? err.message : String(err);
468
+ return {
469
+ ok: false,
470
+ error: `Docker is required to run this environment image (${effectiveImage}) but container startup failed: ${reason}`,
471
+ status: 503,
472
+ };
473
+ }
474
+ containerId = containerInfo.containerId;
475
+ containerName = containerInfo.name;
476
+ containerImage = effectiveImage;
477
+ if (onProgress) await onProgress("creating_container", "Container running", "done");
478
+
479
+ // Copy workspace
480
+ if (onProgress) await onProgress("copying_workspace", "Copying workspace files...", "in_progress");
481
+ try {
482
+ await containerManager.copyWorkspaceToContainer(containerInfo.containerId, cwd!);
483
+ containerManager.reseedGitAuth(containerInfo.containerId);
484
+ if (onProgress) await onProgress("copying_workspace", "Workspace copied", "done");
485
+ } catch (err) {
486
+ containerManager.removeContainer(tempId);
487
+ const reason = err instanceof Error ? err.message : String(err);
488
+ return { ok: false, error: `Failed to copy workspace to container: ${reason}`, status: 503 };
489
+ }
490
+
491
+ // Git operations inside container
492
+ if (body.branch) {
493
+ const repoInfo = cwd ? gitUtils.getRepoInfo(cwd) : null;
494
+ if (onProgress) await onProgress("fetching_git", "Fetching from remote (in container)...", "in_progress");
495
+ const gitResult = containerManager.gitOpsInContainer(containerInfo.containerId, {
496
+ branch: body.branch,
497
+ currentBranch: repoInfo?.currentBranch || "HEAD",
498
+ createBranch: body.createBranch,
499
+ defaultBranch: repoInfo?.defaultBranch,
500
+ });
501
+ if (onProgress) await onProgress("fetching_git", gitResult.fetchOk ? "Fetch complete" : "Fetch skipped", "done");
502
+ if (onProgress && repoInfo?.currentBranch !== body.branch) {
503
+ await onProgress("checkout_branch",
504
+ gitResult.checkoutOk ? `On branch ${body.branch}` : "Checkout failed",
505
+ gitResult.checkoutOk ? "done" : "error",
506
+ );
507
+ }
508
+ if (onProgress) await onProgress("pulling_git", gitResult.pullOk ? "Up to date" : "Pull skipped", "done");
509
+ if (gitResult.errors.length > 0) {
510
+ console.warn(`[orchestrator] In-container git ops warnings: ${gitResult.errors.join("; ")}`);
511
+ }
512
+ if (!gitResult.checkoutOk) {
513
+ containerManager.removeContainer(tempId);
514
+ return {
515
+ ok: false,
516
+ error: `Failed to checkout branch "${body.branch}" inside container: ${gitResult.errors.join("; ")}`,
517
+ status: 400,
518
+ };
519
+ }
520
+ }
521
+
522
+ // Init script
523
+ const initScript = companionSandbox?.initScript?.trim();
524
+ if (initScript) {
525
+ if (onProgress) await onProgress("running_init_script", "Running init script...", "in_progress");
526
+ try {
527
+ console.log(`[orchestrator] Running init script for sandbox "${companionSandbox?.name || "sandbox"}" in container ${containerInfo.name}...`);
528
+ const initTimeout = Number(process.env.COMPANION_INIT_SCRIPT_TIMEOUT) || 120_000;
529
+ const result = await containerManager.execInContainerAsync(
530
+ containerInfo.containerId,
531
+ ["sh", "-lc", initScript],
532
+ {
533
+ timeout: initTimeout,
534
+ onOutput: onProgress
535
+ ? (line: string) => { onProgress("running_init_script", "Running init script...", "in_progress", line).catch(() => {}); }
536
+ : undefined,
537
+ },
538
+ );
539
+ if (result.exitCode !== 0) {
540
+ console.error(`[orchestrator] Init script failed (exit ${result.exitCode}):\n${result.output}`);
541
+ containerManager.removeContainer(tempId);
542
+ const truncated = result.output.length > 2000
543
+ ? result.output.slice(0, 500) + "\n...[truncated]...\n" + result.output.slice(-1500)
544
+ : result.output;
545
+ return { ok: false, error: `Init script failed (exit ${result.exitCode}):\n${truncated}`, status: 503 };
546
+ }
547
+ if (onProgress) await onProgress("running_init_script", "Init script complete", "done");
548
+ console.log(`[orchestrator] Init script completed successfully for sandbox "${companionSandbox?.name || "sandbox"}"`);
549
+ } catch (e) {
550
+ containerManager.removeContainer(tempId);
551
+ const reason = e instanceof Error ? e.message : String(e);
552
+ return { ok: false, error: `Init script execution failed: ${reason}`, status: 503 };
553
+ }
554
+ }
555
+ }
556
+
557
+ // --- Step: Launch CLI ---
558
+ if (onProgress) await onProgress("launching_cli", `Launching ${backend === "codex" ? "Codex" : "Claude Code"}...`, "in_progress");
559
+
560
+ let session: SdkSessionInfo;
561
+ try {
562
+ session = this.launcher.launch({
563
+ model: body.model,
564
+ permissionMode: body.permissionMode,
565
+ cwd,
566
+ claudeBinary: body.claudeBinary,
567
+ codexBinary: body.codexBinary,
568
+ codexInternetAccess: backend === "codex",
569
+ codexSandbox: backend === "codex" ? "danger-full-access" : undefined,
570
+ allowedTools: body.allowedTools,
571
+ env: envVars,
572
+ backendType: backend,
573
+ containerId,
574
+ containerName,
575
+ containerImage,
576
+ containerCwd: containerInfo?.containerCwd,
577
+ resumeSessionAt,
578
+ forkSession,
579
+ systemPrompt: backend === "codex" ? linearSystemPrompt : undefined,
580
+ sandboxSlug: sandboxEnabled ? (body.sandboxSlug || undefined) : undefined,
581
+ });
582
+ } catch (e) {
583
+ // Clean up container if it was created but launch failed
584
+ if (containerId) containerManager.removeContainer(containerId);
585
+ const reason = e instanceof Error ? e.message : String(e);
586
+ return { ok: false, error: `Failed to launch CLI: ${reason}`, status: 503 };
587
+ }
588
+
589
+ // Post-launch wiring
590
+ if (containerInfo) {
591
+ containerManager.retrack(containerInfo.containerId, session.sessionId);
592
+ this.wsBridge.markContainerized(session.sessionId, cwd!);
593
+ }
594
+
595
+ if (worktreeInfo) {
596
+ this.worktreeTracker.addMapping({
597
+ sessionId: session.sessionId,
598
+ repoRoot: worktreeInfo.repoRoot,
599
+ branch: worktreeInfo.branch,
600
+ actualBranch: worktreeInfo.actualBranch,
601
+ worktreePath: worktreeInfo.worktreePath,
602
+ createdAt: Date.now(),
603
+ });
604
+ }
605
+
606
+ if (linearSystemPrompt && backend === "claude") {
607
+ this.wsBridge.injectSystemPrompt(session.sessionId, linearSystemPrompt);
608
+ }
609
+
610
+ const discovered = await discoverCommandsAndSkills(cwd).catch(() => ({ slash_commands: [] as string[], skills: [] as string[] }));
611
+ this.wsBridge.prePopulateCommands(session.sessionId, discovered.slash_commands, discovered.skills);
612
+
613
+ if (onProgress) await onProgress("launching_cli", "Session started", "done");
614
+
615
+ metricsCollector.recordSessionCreated(backend);
616
+ metricsCollector.recordSessionSpawned(session.sessionId);
617
+
618
+ return { ok: true, session };
619
+ } catch (e: unknown) {
620
+ const msg = e instanceof Error ? e.message : String(e);
621
+ log.error("orchestrator", "Failed to create session", { error: msg });
622
+ return { ok: false, error: msg, status: 500 };
623
+ }
624
+ }
625
+
626
+ // ── Kill ───────────────────────────────────────────────────────────────────
627
+
628
+ async killSession(sessionId: string): Promise<{ ok: boolean }> {
629
+ const killed = await this.launcher.kill(sessionId);
630
+ if (killed) {
631
+ containerManager.removeContainer(sessionId);
632
+ }
633
+ return { ok: killed };
634
+ }
635
+
636
+ // ── Relaunch ───────────────────────────────────────────────────────────────
637
+
638
+ async relaunchSession(sessionId: string): Promise<{ ok: boolean; error?: string }> {
639
+ const info = this.launcher.getSession(sessionId);
640
+ if (info?.archived) {
641
+ return { ok: false, error: "Session is archived and cannot be relaunched" };
642
+ }
643
+ this.clearAutoRelaunchCount(sessionId);
644
+ const session = this.wsBridge.getSession(sessionId);
645
+ if (session?.stateMachine) {
646
+ session.stateMachine.transition("starting", "relaunch_initiated");
647
+ }
648
+ return this.launcher.relaunch(sessionId);
649
+ }
650
+
651
+ // ── Archive ────────────────────────────────────────────────────────────────
652
+
653
+ async archiveSession(sessionId: string, options?: ArchiveSessionOptions): Promise<ArchiveSessionResult> {
654
+ let linearTransitionResult: ArchiveSessionResult["linearTransition"];
655
+ const linearTransition = options?.linearTransition;
656
+
657
+ if (linearTransition && linearTransition !== "none") {
658
+ const linkedIssue = sessionLinearIssues.getLinearIssue(sessionId);
659
+ if (linkedIssue) {
660
+ const resolved = resolveApiKey(linkedIssue.connectionId);
661
+ if (resolved) {
662
+ const { apiKey: linearApiKey, connectionId: resolvedConnId } = resolved;
663
+ const settings = getSettings();
664
+ const conn = resolvedConnId !== "legacy" ? getConnection(resolvedConnId) : null;
665
+ let targetStateId = "";
666
+
667
+ if (linearTransition === "backlog" && linkedIssue.teamId) {
668
+ const teams = await fetchLinearTeamStates(linearApiKey);
669
+ const team = teams.find((t) => t.id === linkedIssue.teamId);
670
+ const backlogState = team?.states.find((s) => s.type === "backlog");
671
+ if (backlogState) targetStateId = backlogState.id;
672
+ } else if (linearTransition === "configured") {
673
+ const archiveStateId = conn ? conn.archiveTransitionStateId : settings.linearArchiveTransitionStateId;
674
+ targetStateId = archiveStateId.trim();
675
+ }
676
+
677
+ if (targetStateId) {
678
+ try {
679
+ linearTransitionResult = await transitionLinearIssue(linkedIssue.id, targetStateId, linearApiKey, resolvedConnId);
680
+ } catch {
681
+ linearTransitionResult = { ok: false, error: "Transition failed unexpectedly" };
682
+ }
683
+ } else {
684
+ linearTransitionResult = { ok: true, skipped: true };
685
+ }
686
+ }
687
+ }
688
+ }
689
+
690
+ this.intentionalKills.add(sessionId);
691
+ this.cancelKeepaliveTimer(sessionId);
692
+ this.wsBridge.cancelDisconnectTimer(sessionId);
693
+ await this.launcher.kill(sessionId);
694
+ containerManager.removeContainer(sessionId);
695
+ this.prPoller.unwatch(sessionId);
696
+
697
+ const worktreeResult = this.cleanupWorktree(sessionId, options?.force);
698
+ this.launcher.setArchived(sessionId, true);
699
+ this.sessionStore.setArchived(sessionId, true);
700
+
701
+ return { ok: true, worktree: worktreeResult, linearTransition: linearTransitionResult };
702
+ }
703
+
704
+ // ── Delete ─────────────────────────────────────────────────────────────────
705
+
706
+ async deleteSession(sessionId: string): Promise<DeleteSessionResult> {
707
+ this.intentionalKills.add(sessionId);
708
+ this.cancelKeepaliveTimer(sessionId);
709
+ this.wsBridge.cancelDisconnectTimer(sessionId);
710
+ await this.launcher.kill(sessionId);
711
+ containerManager.removeContainer(sessionId);
712
+ const worktreeResult = this.cleanupWorktree(sessionId, true);
713
+ this.prPoller.unwatch(sessionId);
714
+ sessionLinearIssues.removeLinearIssue(sessionId);
715
+ this.launcher.removeSession(sessionId);
716
+ this.wsBridge.closeSession(sessionId);
717
+ this.autoRelaunchCounts.delete(sessionId);
718
+ this.relaunchExhaustedNotified.delete(sessionId);
719
+ this.relaunchingSet.delete(sessionId);
720
+ this.intentionalKills.delete(sessionId);
721
+ return { ok: true, worktree: worktreeResult };
722
+ }
723
+
724
+ // ── Unarchive ──────────────────────────────────────────────────────────────
725
+
726
+ unarchiveSession(sessionId: string): { ok: boolean } {
727
+ this.launcher.setArchived(sessionId, false);
728
+ this.sessionStore.setArchived(sessionId, false);
729
+ return { ok: true };
730
+ }
731
+
732
+ // ── Auto-relaunch count ────────────────────────────────────────────────────
733
+
734
+ clearAutoRelaunchCount(sessionId: string): void {
735
+ this.autoRelaunchCounts.delete(sessionId);
736
+ this.relaunchExhaustedNotified.delete(sessionId);
737
+ }
738
+
739
+ // ── Event registration ─────────────────────────────────────────────────────
740
+
741
+ /** Register a callback for session exit events. Returns unsubscribe function. */
742
+ onSessionExited(cb: (sessionId: string, exitCode: number | null) => void): () => void {
743
+ this.exitCallbacks.push(cb);
744
+ return () => {
745
+ const idx = this.exitCallbacks.indexOf(cb);
746
+ if (idx !== -1) this.exitCallbacks.splice(idx, 1);
747
+ };
748
+ }
749
+
750
+ // ── Query delegation ───────────────────────────────────────────────────────
751
+
752
+ getSession(sessionId: string): SdkSessionInfo | undefined {
753
+ return this.launcher.getSession(sessionId);
754
+ }
755
+
756
+ // ── Cleanup ────────────────────────────────────────────────────────────────
757
+
758
+ shutdown(): void {
759
+ // Timers are owned by the process lifecycle
760
+ }
761
+
762
+ // ── Private: Auto-relaunch ─────────────────────────────────────────────────
763
+
764
+ private async handleAutoRelaunch(sessionId: string): Promise<void> {
765
+ if (this.relaunchingSet.has(sessionId)) return;
766
+ const info = this.launcher.getSession(sessionId);
767
+ if (info?.archived) return;
768
+
769
+ // If we've already notified the user about relaunch exhaustion, bail out
770
+ // silently. Without this, every reconnect event from a dead session
771
+ // (e.g. deleted container) re-logs the "limit reached" warning endlessly.
772
+ if (this.relaunchExhaustedNotified.has(sessionId)) return;
773
+
774
+ this.relaunchingSet.add(sessionId);
775
+
776
+ await new Promise((r) => setTimeout(r, RELAUNCH_GRACE_MS));
777
+ if (this.wsBridge.isCliConnected(sessionId)) { this.relaunchingSet.delete(sessionId); return; }
778
+ const freshInfo = this.launcher.getSession(sessionId);
779
+ if (freshInfo && (freshInfo.state === "connected" || freshInfo.state === "running")) {
780
+ this.relaunchingSet.delete(sessionId); return;
781
+ }
782
+ // Only check PID liveness if the session is NOT already "exited".
783
+ // After idle-kill or explicit kill(), the PID field stays set but the
784
+ // process is dead. If the kernel recycles the PID to a different process,
785
+ // kill(pid, 0) would incorrectly succeed, preventing any relaunch.
786
+ // For containerized sessions, use container liveness instead of PID check
787
+ // (the PID is the `docker exec` wrapper, which exits immediately for some
788
+ // transports and is unreliable for container health).
789
+ if (freshInfo && freshInfo.state !== "exited") {
790
+ if (freshInfo.containerId) {
791
+ const containerState = containerManager.isContainerAlive(freshInfo.containerId);
792
+ if (containerState === "running") {
793
+ this.relaunchingSet.delete(sessionId);
794
+ return;
795
+ }
796
+ } else if (freshInfo.pid) {
797
+ try { process.kill(freshInfo.pid, 0); this.relaunchingSet.delete(sessionId); return; } catch {}
798
+ }
799
+ }
800
+
801
+ const count = this.autoRelaunchCounts.get(sessionId) ?? 0;
802
+ if (count >= MAX_AUTO_RELAUNCHES) {
803
+ metricsCollector.recordRelaunchExhausted();
804
+ log.warn("orchestrator", "Auto-relaunch limit reached", { sessionId, maxAttempts: MAX_AUTO_RELAUNCHES });
805
+ this.wsBridge.broadcastToSession(sessionId, {
806
+ type: "error",
807
+ message: "Session keeps crashing. Please relaunch manually.",
808
+ });
809
+ this.relaunchExhaustedNotified.add(sessionId);
810
+ this.relaunchingSet.delete(sessionId);
811
+ return;
812
+ }
813
+
814
+ if (freshInfo && freshInfo.state !== "starting") {
815
+ this.autoRelaunchCounts.set(sessionId, count + 1);
816
+ metricsCollector.recordRelaunchAttempted();
817
+ log.info("orchestrator", "Auto-relaunching CLI", { sessionId, attempt: count + 1, maxAttempts: MAX_AUTO_RELAUNCHES });
818
+ const session = this.wsBridge.getSession(sessionId);
819
+ if (session?.stateMachine) {
820
+ session.stateMachine.transition("starting", "relaunch_initiated");
821
+ }
822
+ try {
823
+ const result = await this.launcher.relaunch(sessionId);
824
+ if (!result.ok && result.error) {
825
+ this.wsBridge.broadcastToSession(sessionId, { type: "error", message: result.error });
826
+ } else if (result.ok) {
827
+ metricsCollector.recordRelaunchSucceeded();
828
+ this.autoRelaunchCounts.delete(sessionId);
829
+ this.relaunchExhaustedNotified.delete(sessionId);
830
+ // Clear intentionalKills so future crashes can use proactive keepalive.
831
+ // After a successful relaunch, the session is alive again — any prior
832
+ // idle-kill intent no longer applies.
833
+ this.intentionalKills.delete(sessionId);
834
+ }
835
+ // ok=false without error: keep count to preserve the retry budget
836
+ } finally {
837
+ setTimeout(() => this.relaunchingSet.delete(sessionId), RELAUNCH_COOLDOWN_MS);
838
+ }
839
+ } else {
840
+ this.relaunchingSet.delete(sessionId);
841
+ }
842
+ }
843
+
844
+ // ── Private: Proactive keepalive ────────────────────────────────────────────
845
+
846
+ /**
847
+ * Schedules a proactive relaunch of a crashed CLI process, regardless of
848
+ * whether any browsers are connected. Uses exponential backoff (3s, 6s, 12s)
849
+ * based on the auto-relaunch attempt count.
850
+ *
851
+ * Skips relaunch for:
852
+ * - Intentional kills (idle-kill, manual delete/archive)
853
+ * - Archived sessions
854
+ * - Sessions that have exhausted their relaunch budget
855
+ */
856
+ private scheduleProactiveRelaunch(sessionId: string): void {
857
+ // Skip if this was an intentional kill. Use has() instead of delete() so
858
+ // the guard is preserved for handleAutoRelaunch (debounce path fires later).
859
+ if (this.intentionalKills.has(sessionId)) return;
860
+
861
+ const info = this.launcher.getSession(sessionId);
862
+ if (!info || info.archived) return;
863
+
864
+ // Skip if already at relaunch limit
865
+ if (this.relaunchExhaustedNotified.has(sessionId)) return;
866
+
867
+ // Skip if a relaunch is already in progress (e.g. triggered by browser reconnect)
868
+ if (this.relaunchingSet.has(sessionId)) return;
869
+
870
+ // Exponential backoff: 3s → 6s → 12s based on attempt count
871
+ const attempt = this.autoRelaunchCounts.get(sessionId) ?? 0;
872
+ const delay = KEEPALIVE_BASE_DELAY_MS * Math.pow(2, attempt);
873
+
874
+ log.info("orchestrator", "Scheduling proactive keepalive relaunch", {
875
+ sessionId,
876
+ attempt: attempt + 1,
877
+ maxAttempts: MAX_AUTO_RELAUNCHES,
878
+ delayMs: delay,
879
+ });
880
+
881
+ // Cancel any existing keepalive timer for this session
882
+ this.cancelKeepaliveTimer(sessionId);
883
+
884
+ const timer = setTimeout(async () => {
885
+ this.keepaliveTimers.delete(sessionId);
886
+
887
+ // Re-check conditions — state may have changed during the delay
888
+ const freshInfo = this.launcher.getSession(sessionId);
889
+ if (!freshInfo || freshInfo.archived) return;
890
+ if (freshInfo.state === "connected" || freshInfo.state === "running") return;
891
+
892
+ // Delegate to the existing auto-relaunch mechanism which handles
893
+ // budget, PID checks, state transitions, and cooldowns.
894
+ await this.handleAutoRelaunch(sessionId);
895
+ }, delay);
896
+
897
+ this.keepaliveTimers.set(sessionId, timer);
898
+ }
899
+
900
+ private cancelKeepaliveTimer(sessionId: string): void {
901
+ const timer = this.keepaliveTimers.get(sessionId);
902
+ if (timer) {
903
+ clearTimeout(timer);
904
+ this.keepaliveTimers.delete(sessionId);
905
+ }
906
+ }
907
+
908
+ // ── Private: Auto-naming ───────────────────────────────────────────────────
909
+
910
+ private async handleAutoNaming(sessionId: string, firstUserMessage: string): Promise<void> {
911
+ if (sessionNames.getName(sessionId)) return;
912
+ if (!getSettings().anthropicApiKey.trim()) return;
913
+ const info = this.launcher.getSession(sessionId);
914
+ const model = info?.model || "claude-sonnet-4-6";
915
+ console.log(`[orchestrator] Auto-naming session ${sessionId} via Anthropic with model ${model}...`);
916
+ const title = await generateSessionTitle(firstUserMessage, model);
917
+ if (title && !sessionNames.getName(sessionId)) {
918
+ console.log(`[orchestrator] Auto-named session ${sessionId}: "${title}"`);
919
+ sessionNames.setName(sessionId, title);
920
+ this.wsBridge.broadcastNameUpdate(sessionId, title);
921
+ }
922
+ }
923
+
924
+ // ── Private: Reconnection watchdog ─────────────────────────────────────────
925
+
926
+ private startReconnectionWatchdog(): void {
927
+ const starting = this.launcher.getStartingSessions();
928
+ if (starting.length > 0) {
929
+ console.log(`[orchestrator] Waiting ${RECONNECT_GRACE_MS / 1000}s for ${starting.length} CLI process(es) to reconnect...`);
930
+ setTimeout(async () => {
931
+ const stale = this.launcher.getStartingSessions();
932
+ for (const info of stale) {
933
+ if (info.archived) continue;
934
+ console.log(`[orchestrator] CLI for session ${info.sessionId} did not reconnect, relaunching...`);
935
+ await this.launcher.relaunch(info.sessionId);
936
+ }
937
+ }, RECONNECT_GRACE_MS);
938
+ }
939
+ }
940
+
941
+ // ── Private: Worktree cleanup ──────────────────────────────────────────────
942
+
943
+ private cleanupWorktree(
944
+ sessionId: string,
945
+ force?: boolean,
946
+ ): { cleaned?: boolean; dirty?: boolean; path?: string } | undefined {
947
+ const mapping = this.worktreeTracker.getBySession(sessionId);
948
+ if (!mapping) return undefined;
949
+
950
+ if (this.worktreeTracker.isWorktreeInUse(mapping.worktreePath, sessionId)) {
951
+ this.worktreeTracker.removeBySession(sessionId);
952
+ return { cleaned: false, path: mapping.worktreePath };
953
+ }
954
+
955
+ const dirty = gitUtils.isWorktreeDirty(mapping.worktreePath);
956
+ if (dirty && !force) {
957
+ return { cleaned: false, dirty: true, path: mapping.worktreePath };
958
+ }
959
+
960
+ const branchToDelete =
961
+ mapping.actualBranch && mapping.actualBranch !== mapping.branch
962
+ ? mapping.actualBranch
963
+ : undefined;
964
+ const result = gitUtils.removeWorktree(mapping.repoRoot, mapping.worktreePath, {
965
+ force: dirty,
966
+ branchToDelete,
967
+ });
968
+ if (result.removed) {
969
+ this.worktreeTracker.removeBySession(sessionId);
970
+ }
971
+ return { cleaned: result.removed, path: mapping.worktreePath };
972
+ }
973
+ }