@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,473 @@
1
+ import type { CliLauncher, SdkSessionInfo } from "./cli-launcher.js";
2
+ import type { WsBridge } from "./ws-bridge.js";
3
+ import type { WorktreeTracker } from "./worktree-tracker.js";
4
+ import type { CreationStepId } from "./session-types.js";
5
+ import type { ContainerConfig, ContainerInfo } from "./container-manager.js";
6
+ import * as envManager from "./env-manager.js";
7
+ import * as sandboxManager from "./sandbox-manager.js";
8
+ import * as gitUtils from "./git-utils.js";
9
+ import { containerManager } from "./container-manager.js";
10
+ import { hasContainerClaudeAuth } from "./claude-container-auth.js";
11
+ import { hasContainerCodexAuth } from "./codex-container-auth.js";
12
+ import { imagePullManager } from "./image-pull-manager.js";
13
+ import { getConnection } from "./linear-connections.js";
14
+ import { buildLinearSystemPrompt } from "./linear-prompt-builder.js";
15
+ import { discoverCommandsAndSkills } from "./commands-discovery.js";
16
+ import { VSCODE_EDITOR_CONTAINER_PORT, CODEX_APP_SERVER_CONTAINER_PORT, NOVNC_CONTAINER_PORT } from "./constants.js";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Types
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export type ProgressCallback = (
23
+ step: CreationStepId,
24
+ label: string,
25
+ status: "in_progress" | "done" | "error",
26
+ detail?: string,
27
+ ) => Promise<void>;
28
+
29
+ export interface SessionCreationDeps {
30
+ launcher: CliLauncher;
31
+ wsBridge: WsBridge;
32
+ worktreeTracker: WorktreeTracker;
33
+ }
34
+
35
+ export interface SessionCreationResult {
36
+ session: SdkSessionInfo;
37
+ }
38
+
39
+ export class SessionCreationError extends Error {
40
+ constructor(
41
+ message: string,
42
+ public readonly statusCode: number = 500,
43
+ public readonly step?: CreationStepId,
44
+ ) {
45
+ super(message);
46
+ this.name = "SessionCreationError";
47
+ }
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Helper: emit progress if a callback is provided (no-op otherwise)
52
+ // ---------------------------------------------------------------------------
53
+
54
+ async function emit(
55
+ onProgress: ProgressCallback | undefined,
56
+ step: CreationStepId,
57
+ label: string,
58
+ status: "in_progress" | "done" | "error",
59
+ detail?: string,
60
+ ): Promise<void> {
61
+ if (onProgress) {
62
+ await onProgress(step, label, status, detail);
63
+ }
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Main service function
68
+ // ---------------------------------------------------------------------------
69
+
70
+ export async function executeSessionCreation(
71
+ body: Record<string, unknown>,
72
+ deps: SessionCreationDeps,
73
+ onProgress?: ProgressCallback,
74
+ ): Promise<SessionCreationResult> {
75
+ const { launcher, wsBridge, worktreeTracker } = deps;
76
+
77
+ // -- Parse input --
78
+ const resumeSessionAt =
79
+ typeof body.resumeSessionAt === "string" && (body.resumeSessionAt as string).trim()
80
+ ? (body.resumeSessionAt as string).trim()
81
+ : undefined;
82
+ const forkSession = body.forkSession === true;
83
+ const backend = (body.backend as string) ?? "claude";
84
+ if (backend !== "claude" && backend !== "codex") {
85
+ throw new SessionCreationError(`Invalid backend: ${String(backend)}`, 400);
86
+ }
87
+
88
+ // -- Step: Resolve environment --
89
+ await emit(onProgress, "resolving_env", "Resolving environment...", "in_progress");
90
+
91
+ let envVars: Record<string, string> | undefined = body.env as Record<string, string> | undefined;
92
+ const companionEnv = body.envSlug ? envManager.getEnv(body.envSlug as string) : null;
93
+ if (body.envSlug && companionEnv) {
94
+ console.log(
95
+ `[session-creation] Injecting env "${companionEnv.name}" (${Object.keys(companionEnv.variables).length} vars):`,
96
+ Object.keys(companionEnv.variables).join(", "),
97
+ );
98
+ envVars = { ...companionEnv.variables, ...(body.env as Record<string, string>) };
99
+ } else if (body.envSlug) {
100
+ console.warn(`[session-creation] Environment "${body.envSlug}" not found, ignoring`);
101
+ }
102
+
103
+ // Resolve sandbox configuration
104
+ const sandboxEnabled = body.sandboxEnabled === true;
105
+ const companionSandbox = body.sandboxSlug ? sandboxManager.getSandbox(body.sandboxSlug as string) : null;
106
+ if (sandboxEnabled && body.sandboxSlug && !companionSandbox) {
107
+ throw new SessionCreationError(`Sandbox "${body.sandboxSlug}" not found`, 404, "resolving_env");
108
+ }
109
+
110
+ // Inject LINEAR_API_KEY if a Linear connection is specified
111
+ let linearSystemPrompt: string | undefined;
112
+ if (body.linearConnectionId) {
113
+ const conn = getConnection(body.linearConnectionId as string);
114
+ if (conn?.apiKey) {
115
+ envVars = { ...envVars, LINEAR_API_KEY: conn.apiKey };
116
+ linearSystemPrompt = buildLinearSystemPrompt(conn, body.linearIssue as Parameters<typeof buildLinearSystemPrompt>[1]);
117
+ }
118
+ }
119
+
120
+ // Resolve Docker image early
121
+ let effectiveImage: string | null = null;
122
+ if (sandboxEnabled) {
123
+ effectiveImage = "the-companion:latest";
124
+ } else if ((body.container as Record<string, unknown>)?.image) {
125
+ effectiveImage = (body.container as Record<string, unknown>).image as string;
126
+ }
127
+ const isDockerSession = !!effectiveImage;
128
+
129
+ await emit(onProgress, "resolving_env", "Environment resolved", "done");
130
+
131
+ // -- Step: Git operations (host-only) --
132
+ let cwd = body.cwd as string | undefined;
133
+ let worktreeInfo: {
134
+ isWorktree: boolean;
135
+ repoRoot: string;
136
+ branch: string;
137
+ actualBranch: string;
138
+ worktreePath: string;
139
+ } | undefined;
140
+
141
+ // Validate branch name
142
+ if (body.branch && !/^[a-zA-Z0-9/_.\-]+$/.test(body.branch as string)) {
143
+ throw new SessionCreationError("Invalid branch name", 400, "checkout_branch");
144
+ }
145
+
146
+ if (!isDockerSession && body.useWorktree && body.branch && cwd) {
147
+ const repoInfo = gitUtils.getRepoInfo(cwd);
148
+ if (repoInfo) {
149
+ await emit(onProgress, "fetching_git", "Fetching from remote...", "in_progress");
150
+ const fetchResult = gitUtils.gitFetch(repoInfo.repoRoot);
151
+ if (!fetchResult.success) {
152
+ console.warn(`[session-creation] git fetch failed (non-fatal): ${fetchResult.output}`);
153
+ }
154
+ await emit(onProgress, "fetching_git", fetchResult.success ? "Fetch complete" : "Fetch skipped (offline?)", "done");
155
+
156
+ await emit(onProgress, "creating_worktree", "Creating worktree...", "in_progress");
157
+ const result = gitUtils.ensureWorktree(repoInfo.repoRoot, body.branch as string, {
158
+ baseBranch: repoInfo.defaultBranch,
159
+ createBranch: body.createBranch as boolean | undefined,
160
+ forceNew: true,
161
+ });
162
+ cwd = result.worktreePath;
163
+ worktreeInfo = {
164
+ isWorktree: true,
165
+ repoRoot: repoInfo.repoRoot,
166
+ branch: body.branch as string,
167
+ actualBranch: result.actualBranch,
168
+ worktreePath: result.worktreePath,
169
+ };
170
+ await emit(onProgress, "creating_worktree", "Worktree ready", "done");
171
+ }
172
+ } else if (!isDockerSession && body.branch && cwd) {
173
+ const repoInfo = gitUtils.getRepoInfo(cwd);
174
+ if (repoInfo) {
175
+ await emit(onProgress, "fetching_git", "Fetching from remote...", "in_progress");
176
+ const fetchResult = gitUtils.gitFetch(repoInfo.repoRoot);
177
+ if (!fetchResult.success) {
178
+ console.warn(`[session-creation] git fetch failed (non-fatal): ${fetchResult.output}`);
179
+ }
180
+ await emit(onProgress, "fetching_git", fetchResult.success ? "Fetch complete" : "Fetch skipped (offline?)", "done");
181
+
182
+ if (repoInfo.currentBranch !== body.branch) {
183
+ await emit(onProgress, "checkout_branch", `Checking out ${body.branch}...`, "in_progress");
184
+ gitUtils.checkoutOrCreateBranch(repoInfo.repoRoot, body.branch as string, {
185
+ createBranch: body.createBranch as boolean | undefined,
186
+ defaultBranch: repoInfo.defaultBranch,
187
+ });
188
+ await emit(onProgress, "checkout_branch", `On branch ${body.branch}`, "done");
189
+ }
190
+
191
+ await emit(onProgress, "pulling_git", "Pulling latest changes...", "in_progress");
192
+ const pullResult = gitUtils.gitPull(repoInfo.repoRoot);
193
+ if (!pullResult.success) {
194
+ console.warn(`[session-creation] git pull warning (non-fatal): ${pullResult.output}`);
195
+ }
196
+ await emit(onProgress, "pulling_git", "Up to date", "done");
197
+ }
198
+ }
199
+
200
+ // -- Step: Container creation --
201
+ let containerInfo: ContainerInfo | undefined;
202
+ let containerId: string | undefined;
203
+ let containerName: string | undefined;
204
+ let containerImage: string | undefined;
205
+ let tempId: string | undefined;
206
+
207
+ // Validate cwd before container operations (cwd! assertions below rely on this)
208
+ if (effectiveImage && !cwd) {
209
+ throw new SessionCreationError(
210
+ "Working directory (cwd) is required for containerized sessions",
211
+ 400,
212
+ );
213
+ }
214
+
215
+ // Auth checks for containerized sessions
216
+ if (effectiveImage && backend === "claude" && !hasContainerClaudeAuth(envVars)) {
217
+ throw new SessionCreationError(
218
+ "Containerized Claude requires auth available inside the container. " +
219
+ "Set ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN / CLAUDE_CODE_AUTH_TOKEN) in the selected environment.",
220
+ 400,
221
+ );
222
+ }
223
+ if (effectiveImage && backend === "codex" && !hasContainerCodexAuth(envVars)) {
224
+ throw new SessionCreationError(
225
+ "Containerized Codex requires auth available inside the container. " +
226
+ "Set OPENAI_API_KEY in the selected environment, or ensure ~/.codex/auth.json exists on the host.",
227
+ 400,
228
+ );
229
+ }
230
+
231
+ if (effectiveImage) {
232
+ // -- Image pull --
233
+ if (!imagePullManager.isReady(effectiveImage)) {
234
+ const pullState = imagePullManager.getState(effectiveImage);
235
+ if (pullState.status === "idle" || pullState.status === "error") {
236
+ imagePullManager.ensureImage(effectiveImage);
237
+ }
238
+
239
+ await emit(onProgress, "pulling_image", "Pulling Docker image...", "in_progress");
240
+
241
+ // Stream pull progress lines if the caller wants progress
242
+ let unsub: (() => void) | undefined;
243
+ if (onProgress) {
244
+ unsub = imagePullManager.onProgress(effectiveImage, (line) => {
245
+ emit(onProgress, "pulling_image", "Pulling Docker image...", "in_progress", line).catch(() => {});
246
+ });
247
+ }
248
+
249
+ const ready = await imagePullManager.waitForReady(effectiveImage, 300_000);
250
+ unsub?.();
251
+
252
+ if (ready) {
253
+ await emit(onProgress, "pulling_image", "Image ready", "done");
254
+ } else {
255
+ const state = imagePullManager.getState(effectiveImage);
256
+ throw new SessionCreationError(
257
+ state.error ||
258
+ `Docker image ${effectiveImage} could not be pulled or built. Use the environment manager to pull/build the image first.`,
259
+ 503,
260
+ "pulling_image",
261
+ );
262
+ }
263
+ }
264
+
265
+ // -- Create container --
266
+ await emit(onProgress, "creating_container", "Starting container...", "in_progress");
267
+ tempId = crypto.randomUUID().slice(0, 8);
268
+ const requestedPorts = Array.isArray((body.container as Record<string, unknown>)?.ports)
269
+ ? ((body.container as Record<string, unknown>).ports as number[]).map(Number).filter((n: number) => n > 0)
270
+ : [];
271
+ const containerPorts: (number | { port: number; hostIp?: string })[] = [
272
+ ...Array.from(new Set([
273
+ ...requestedPorts.filter((p: number) => p !== NOVNC_CONTAINER_PORT),
274
+ VSCODE_EDITOR_CONTAINER_PORT,
275
+ ...(backend === "codex" ? [CODEX_APP_SERVER_CONTAINER_PORT] : []),
276
+ ])),
277
+ { port: NOVNC_CONTAINER_PORT, hostIp: "127.0.0.1" },
278
+ ];
279
+ const cConfig: ContainerConfig = {
280
+ image: effectiveImage,
281
+ ports: containerPorts,
282
+ volumes: (body.container as Record<string, unknown>)?.volumes as string[] | undefined,
283
+ env: { ...(envVars ?? {}), DISPLAY: ":99" },
284
+ privileged: sandboxEnabled && effectiveImage === "the-companion:latest",
285
+ };
286
+ try {
287
+ containerInfo = containerManager.createContainer(tempId, cwd!, cConfig);
288
+ } catch (err) {
289
+ const reason = err instanceof Error ? err.message : String(err);
290
+ throw new SessionCreationError(
291
+ `Docker is required to run this environment image (${effectiveImage}) ` +
292
+ `but container startup failed: ${reason}`,
293
+ 503,
294
+ "creating_container",
295
+ );
296
+ }
297
+ containerId = containerInfo.containerId;
298
+ containerName = containerInfo.name;
299
+ containerImage = effectiveImage;
300
+ await emit(onProgress, "creating_container", "Container running", "done");
301
+
302
+ // -- Copy workspace --
303
+ await emit(onProgress, "copying_workspace", "Copying workspace files...", "in_progress");
304
+ try {
305
+ await containerManager.copyWorkspaceToContainer(containerInfo.containerId, cwd!);
306
+ containerManager.reseedGitAuth(containerInfo.containerId);
307
+ await emit(onProgress, "copying_workspace", "Workspace copied", "done");
308
+ } catch (err) {
309
+ containerManager.removeContainer(tempId);
310
+ const reason = err instanceof Error ? err.message : String(err);
311
+ throw new SessionCreationError(
312
+ `Failed to copy workspace to container: ${reason}`,
313
+ 503,
314
+ "copying_workspace",
315
+ );
316
+ }
317
+
318
+ // -- Git ops in container --
319
+ if (body.branch) {
320
+ const repoInfo = cwd ? gitUtils.getRepoInfo(cwd) : null;
321
+
322
+ await emit(onProgress, "fetching_git", "Fetching from remote (in container)...", "in_progress");
323
+ const gitResult = containerManager.gitOpsInContainer(containerInfo.containerId, {
324
+ branch: body.branch as string,
325
+ currentBranch: repoInfo?.currentBranch || "HEAD",
326
+ createBranch: body.createBranch as boolean | undefined,
327
+ defaultBranch: repoInfo?.defaultBranch,
328
+ });
329
+ await emit(onProgress, "fetching_git", gitResult.fetchOk ? "Fetch complete" : "Fetch skipped", "done");
330
+
331
+ if (repoInfo?.currentBranch !== body.branch) {
332
+ await emit(
333
+ onProgress,
334
+ "checkout_branch",
335
+ gitResult.checkoutOk ? `On branch ${body.branch}` : "Checkout failed",
336
+ gitResult.checkoutOk ? "done" : "error",
337
+ );
338
+ }
339
+
340
+ await emit(onProgress, "pulling_git", gitResult.pullOk ? "Up to date" : "Pull skipped", "done");
341
+
342
+ if (gitResult.errors.length > 0) {
343
+ console.warn(`[session-creation] In-container git ops warnings: ${gitResult.errors.join("; ")}`);
344
+ }
345
+ if (!gitResult.checkoutOk) {
346
+ containerManager.removeContainer(tempId);
347
+ throw new SessionCreationError(
348
+ `Failed to checkout branch "${body.branch}" inside container: ${gitResult.errors.join("; ")}`,
349
+ 400,
350
+ "checkout_branch",
351
+ );
352
+ }
353
+ }
354
+
355
+ // -- Init script --
356
+ const initScript = companionSandbox?.initScript?.trim();
357
+ if (initScript) {
358
+ await emit(onProgress, "running_init_script", "Running init script...", "in_progress");
359
+ try {
360
+ const initTimeout = Number(process.env.COMPANION_INIT_SCRIPT_TIMEOUT) || 120_000;
361
+ const result = await containerManager.execInContainerAsync(
362
+ containerInfo.containerId,
363
+ ["sh", "-lc", initScript],
364
+ {
365
+ timeout: initTimeout,
366
+ onOutput: onProgress
367
+ ? (line) => {
368
+ emit(onProgress, "running_init_script", "Running init script...", "in_progress", line).catch(() => {});
369
+ }
370
+ : undefined,
371
+ },
372
+ );
373
+ if (result.exitCode !== 0) {
374
+ console.error(
375
+ `[session-creation] Init script failed for sandbox "${companionSandbox?.name || "sandbox"}" (exit ${result.exitCode}):\n${result.output}`,
376
+ );
377
+ containerManager.removeContainer(tempId);
378
+ const truncated =
379
+ result.output.length > 2000
380
+ ? result.output.slice(0, 500) + "\n...[truncated]...\n" + result.output.slice(-1500)
381
+ : result.output;
382
+ throw new SessionCreationError(
383
+ `Init script failed (exit ${result.exitCode}):\n${truncated}`,
384
+ 503,
385
+ "running_init_script",
386
+ );
387
+ }
388
+ console.log(`[session-creation] Init script completed successfully for sandbox "${companionSandbox?.name || "sandbox"}"`);
389
+ await emit(onProgress, "running_init_script", "Init script complete", "done");
390
+ } catch (e) {
391
+ if (e instanceof SessionCreationError) throw e;
392
+ containerManager.removeContainer(tempId);
393
+ const reason = e instanceof Error ? e.message : String(e);
394
+ throw new SessionCreationError(
395
+ `Init script execution failed: ${reason}`,
396
+ 503,
397
+ "running_init_script",
398
+ );
399
+ }
400
+ }
401
+ }
402
+
403
+ // -- Step: Launch CLI --
404
+ await emit(
405
+ onProgress,
406
+ "launching_cli",
407
+ `Launching ${backend === "codex" ? "Codex" : "Claude Code"}...`,
408
+ "in_progress",
409
+ );
410
+
411
+ let session: SdkSessionInfo;
412
+ try {
413
+ session = launcher.launch({
414
+ model: body.model as string | undefined,
415
+ permissionMode: body.permissionMode as string | undefined,
416
+ cwd,
417
+ claudeBinary: body.claudeBinary as string | undefined,
418
+ codexBinary: body.codexBinary as string | undefined,
419
+ codexInternetAccess: backend === "codex",
420
+ codexSandbox: backend === "codex" ? "danger-full-access" : undefined,
421
+ allowedTools: body.allowedTools as string[] | undefined,
422
+ env: envVars,
423
+ backendType: backend,
424
+ containerId,
425
+ containerName,
426
+ containerImage,
427
+ containerCwd: containerInfo?.containerCwd,
428
+ resumeSessionAt,
429
+ forkSession,
430
+ systemPrompt: backend === "codex" ? linearSystemPrompt : undefined,
431
+ sandboxSlug: sandboxEnabled ? ((body.sandboxSlug as string) || undefined) : undefined,
432
+ });
433
+ } catch (err) {
434
+ if (tempId) containerManager.removeContainer(tempId);
435
+ const reason = err instanceof Error ? err.message : String(err);
436
+ throw new SessionCreationError(
437
+ `Failed to launch CLI: ${reason}`,
438
+ 503,
439
+ "launching_cli",
440
+ );
441
+ }
442
+
443
+ // -- Post-launch tracking --
444
+ if (containerInfo) {
445
+ containerManager.retrack(containerInfo.containerId, session.sessionId);
446
+ wsBridge.markContainerized(session.sessionId, cwd!);
447
+ }
448
+
449
+ if (worktreeInfo) {
450
+ worktreeTracker.addMapping({
451
+ sessionId: session.sessionId,
452
+ repoRoot: worktreeInfo.repoRoot,
453
+ branch: worktreeInfo.branch,
454
+ actualBranch: worktreeInfo.actualBranch,
455
+ worktreePath: worktreeInfo.worktreePath,
456
+ createdAt: Date.now(),
457
+ });
458
+ }
459
+
460
+ if (linearSystemPrompt && backend === "claude") {
461
+ wsBridge.injectSystemPrompt(session.sessionId, linearSystemPrompt);
462
+ }
463
+
464
+ const discovered = await discoverCommandsAndSkills(cwd).catch(() => ({
465
+ slash_commands: [] as string[],
466
+ skills: [] as string[],
467
+ }));
468
+ wsBridge.prePopulateCommands(session.sessionId, discovered.slash_commands, discovered.skills);
469
+
470
+ await emit(onProgress, "launching_cli", "Session started", "done");
471
+
472
+ return { session };
473
+ }
@@ -0,0 +1,104 @@
1
+ import { execSync } from "node:child_process";
2
+ import { resolve } from "node:path";
3
+ import type { SessionState } from "./session-types.js";
4
+ import { containerManager } from "./container-manager.js";
5
+
6
+ function shellEscapeSingle(value: string): string {
7
+ return value.replace(/'/g, "'\\''");
8
+ }
9
+
10
+ function runGitCommand(sessionId: string, state: SessionState, command: string): string {
11
+ if (state.is_containerized) {
12
+ const container = containerManager.getContainer(sessionId);
13
+ if (container?.containerId) {
14
+ const containerCwd = container.containerCwd || "/workspace";
15
+ const inner = `cd '${shellEscapeSingle(containerCwd)}' && ${command}`;
16
+ const dockerCmd = `docker exec ${container.containerId} sh -lc ${JSON.stringify(inner)}`;
17
+ return execSync(dockerCmd, { encoding: "utf-8", timeout: 3000 }).trim();
18
+ }
19
+ throw new Error("container not tracked");
20
+ }
21
+
22
+ return execSync(command, {
23
+ cwd: state.cwd,
24
+ encoding: "utf-8",
25
+ timeout: 3000,
26
+ }).trim();
27
+ }
28
+
29
+ function mapContainerPathToHost(sessionId: string, state: SessionState, pathValue: string): string {
30
+ if (!state.is_containerized || !pathValue) return pathValue;
31
+ const container = containerManager.getContainer(sessionId);
32
+ const containerCwd = (container?.containerCwd || "/workspace").replace(/\/+$/, "") || "/";
33
+ const hostCwd = (container?.hostCwd || state.cwd || "").replace(/\/+$/, "") || "/";
34
+
35
+ if (pathValue === containerCwd) return hostCwd;
36
+ if (containerCwd !== "/" && pathValue.startsWith(`${containerCwd}/`)) {
37
+ return `${hostCwd}${pathValue.slice(containerCwd.length)}`;
38
+ }
39
+ return pathValue;
40
+ }
41
+
42
+ export function resolveSessionGitInfo(sessionId: string, state: SessionState): void {
43
+ if (!state.cwd) return;
44
+ const wasContainerized = state.is_containerized;
45
+ const previous = {
46
+ git_branch: state.git_branch,
47
+ is_worktree: state.is_worktree,
48
+ repo_root: state.repo_root,
49
+ git_ahead: state.git_ahead,
50
+ git_behind: state.git_behind,
51
+ };
52
+ try {
53
+ state.git_branch = runGitCommand(sessionId, state, "git rev-parse --abbrev-ref HEAD 2>/dev/null");
54
+
55
+ try {
56
+ const gitDir = runGitCommand(sessionId, state, "git rev-parse --git-dir 2>/dev/null");
57
+ state.is_worktree = gitDir.includes("/worktrees/");
58
+ } catch {
59
+ state.is_worktree = false;
60
+ }
61
+
62
+ try {
63
+ if (state.is_worktree) {
64
+ const commonDir = runGitCommand(sessionId, state, "git rev-parse --git-common-dir 2>/dev/null");
65
+ state.repo_root = resolve(state.cwd, commonDir, "..");
66
+ } else {
67
+ state.repo_root = runGitCommand(sessionId, state, "git rev-parse --show-toplevel 2>/dev/null");
68
+ }
69
+ state.repo_root = mapContainerPathToHost(sessionId, state, state.repo_root);
70
+ } catch {
71
+ // Ignore repo root resolution failures
72
+ }
73
+
74
+ try {
75
+ const counts = runGitCommand(
76
+ sessionId,
77
+ state,
78
+ "git rev-list --left-right --count @{upstream}...HEAD 2>/dev/null",
79
+ );
80
+ const [behind, ahead] = counts.split(/\s+/).map(Number);
81
+ state.git_ahead = ahead || 0;
82
+ state.git_behind = behind || 0;
83
+ } catch {
84
+ state.git_ahead = 0;
85
+ state.git_behind = 0;
86
+ }
87
+ } catch (error) {
88
+ if (state.is_containerized && error instanceof Error && error.message === "container not tracked") {
89
+ state.git_branch = previous.git_branch;
90
+ state.is_worktree = previous.is_worktree;
91
+ state.repo_root = previous.repo_root;
92
+ state.git_ahead = previous.git_ahead;
93
+ state.git_behind = previous.git_behind;
94
+ state.is_containerized = wasContainerized;
95
+ return;
96
+ }
97
+ state.git_branch = "";
98
+ state.is_worktree = false;
99
+ state.repo_root = "";
100
+ state.git_ahead = 0;
101
+ state.git_behind = 0;
102
+ }
103
+ state.is_containerized = wasContainerized;
104
+ }
@@ -0,0 +1,118 @@
1
+ import {
2
+ mkdtempSync,
3
+ rmSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { tmpdir } from "node:os";
9
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
10
+ import {
11
+ getLinearIssue,
12
+ setLinearIssue,
13
+ getAllLinearIssues,
14
+ removeLinearIssue,
15
+ _resetForTest,
16
+ type StoredLinearIssue,
17
+ } from "./session-linear-issues.js";
18
+
19
+ let tempDir: string;
20
+
21
+ const mockIssue: StoredLinearIssue = {
22
+ id: "issue-1",
23
+ identifier: "ENG-123",
24
+ title: "Fix auth bug",
25
+ description: "Authentication is broken when using SSO",
26
+ url: "https://linear.app/team/issue/ENG-123",
27
+ branchName: "eng-123-fix-auth-bug",
28
+ priorityLabel: "High",
29
+ stateName: "In Progress",
30
+ stateType: "started",
31
+ teamName: "Engineering",
32
+ teamKey: "ENG",
33
+ teamId: "team-1",
34
+ };
35
+
36
+ beforeEach(() => {
37
+ tempDir = mkdtempSync(join(tmpdir(), "session-linear-issues-test-"));
38
+ _resetForTest(join(tempDir, "session-linear-issues.json"));
39
+ });
40
+
41
+ afterEach(() => {
42
+ rmSync(tempDir, { recursive: true, force: true });
43
+ });
44
+
45
+ describe("session-linear-issues", () => {
46
+ it("returns undefined for unknown session", () => {
47
+ expect(getLinearIssue("unknown")).toBeUndefined();
48
+ });
49
+
50
+ it("setLinearIssue + getLinearIssue round-trip", () => {
51
+ setLinearIssue("s1", mockIssue);
52
+ expect(getLinearIssue("s1")).toEqual(mockIssue);
53
+ });
54
+
55
+ it("persists to disk", () => {
56
+ setLinearIssue("s1", mockIssue);
57
+ const raw = readFileSync(join(tempDir, "session-linear-issues.json"), "utf-8");
58
+ const data = JSON.parse(raw);
59
+ expect(data.s1).toEqual(mockIssue);
60
+ });
61
+
62
+ it("getAllLinearIssues returns a copy", () => {
63
+ setLinearIssue("s1", mockIssue);
64
+ const all = getAllLinearIssues();
65
+ expect(all.s1).toEqual(mockIssue);
66
+ // Verify it's a copy (mutating doesn't affect internal state)
67
+ all.s2 = mockIssue;
68
+ expect(getLinearIssue("s2")).toBeUndefined();
69
+ });
70
+
71
+ it("removeLinearIssue deletes the mapping", () => {
72
+ setLinearIssue("s1", mockIssue);
73
+ removeLinearIssue("s1");
74
+ expect(getLinearIssue("s1")).toBeUndefined();
75
+ const raw = readFileSync(join(tempDir, "session-linear-issues.json"), "utf-8");
76
+ expect(JSON.parse(raw)).toEqual({});
77
+ });
78
+
79
+ it("overwrites existing issue", () => {
80
+ setLinearIssue("s1", mockIssue);
81
+ const updated = { ...mockIssue, stateName: "Done", stateType: "completed" };
82
+ setLinearIssue("s1", updated);
83
+ expect(getLinearIssue("s1")).toEqual(updated);
84
+ });
85
+
86
+ it("creates parent directories if needed", () => {
87
+ const nestedPath = join(tempDir, "nested", "dir", "issues.json");
88
+ _resetForTest(nestedPath);
89
+ setLinearIssue("s1", mockIssue);
90
+ expect(getLinearIssue("s1")).toEqual(mockIssue);
91
+ });
92
+
93
+ it("loads existing data from disk on first access", () => {
94
+ // Write data to file before any module access
95
+ writeFileSync(
96
+ join(tempDir, "session-linear-issues.json"),
97
+ JSON.stringify({ existing: mockIssue }),
98
+ );
99
+ _resetForTest(join(tempDir, "session-linear-issues.json"));
100
+ expect(getLinearIssue("existing")).toEqual(mockIssue);
101
+ });
102
+
103
+ it("handles corrupt JSON gracefully", () => {
104
+ writeFileSync(join(tempDir, "session-linear-issues.json"), "NOT VALID JSON");
105
+ _resetForTest(join(tempDir, "session-linear-issues.json"));
106
+ expect(getLinearIssue("any")).toBeUndefined();
107
+ });
108
+
109
+ it("supports multiple sessions with different issues", () => {
110
+ const issue2 = { ...mockIssue, id: "issue-2", identifier: "ENG-456", title: "Add dark mode" };
111
+ setLinearIssue("s1", mockIssue);
112
+ setLinearIssue("s2", issue2);
113
+ expect(getLinearIssue("s1")?.identifier).toBe("ENG-123");
114
+ expect(getLinearIssue("s2")?.identifier).toBe("ENG-456");
115
+ const all = getAllLinearIssues();
116
+ expect(Object.keys(all)).toHaveLength(2);
117
+ });
118
+ });