codepiper 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/.env.example +28 -0
  2. package/CHANGELOG.md +10 -0
  3. package/LEGAL_NOTICE.md +39 -0
  4. package/LICENSE +21 -0
  5. package/README.md +524 -0
  6. package/package.json +90 -0
  7. package/packages/cli/package.json +13 -0
  8. package/packages/cli/src/commands/analytics.ts +157 -0
  9. package/packages/cli/src/commands/attach.ts +299 -0
  10. package/packages/cli/src/commands/audit.ts +50 -0
  11. package/packages/cli/src/commands/auth.ts +261 -0
  12. package/packages/cli/src/commands/daemon.ts +162 -0
  13. package/packages/cli/src/commands/doctor.ts +303 -0
  14. package/packages/cli/src/commands/env-set.ts +162 -0
  15. package/packages/cli/src/commands/hook-forward.ts +268 -0
  16. package/packages/cli/src/commands/keys.ts +77 -0
  17. package/packages/cli/src/commands/kill.ts +19 -0
  18. package/packages/cli/src/commands/logs.ts +419 -0
  19. package/packages/cli/src/commands/model.ts +172 -0
  20. package/packages/cli/src/commands/policy-set.ts +185 -0
  21. package/packages/cli/src/commands/policy.ts +227 -0
  22. package/packages/cli/src/commands/providers.ts +114 -0
  23. package/packages/cli/src/commands/resize.ts +34 -0
  24. package/packages/cli/src/commands/send.ts +184 -0
  25. package/packages/cli/src/commands/sessions.ts +202 -0
  26. package/packages/cli/src/commands/slash.ts +92 -0
  27. package/packages/cli/src/commands/start.ts +243 -0
  28. package/packages/cli/src/commands/stop.ts +19 -0
  29. package/packages/cli/src/commands/tail.ts +137 -0
  30. package/packages/cli/src/commands/workflow.ts +786 -0
  31. package/packages/cli/src/commands/workspace.ts +127 -0
  32. package/packages/cli/src/lib/api.ts +78 -0
  33. package/packages/cli/src/lib/args.ts +72 -0
  34. package/packages/cli/src/lib/format.ts +93 -0
  35. package/packages/cli/src/main.ts +563 -0
  36. package/packages/core/package.json +7 -0
  37. package/packages/core/src/config.ts +30 -0
  38. package/packages/core/src/errors.ts +38 -0
  39. package/packages/core/src/eventBus.ts +56 -0
  40. package/packages/core/src/eventBusAdapter.ts +143 -0
  41. package/packages/core/src/index.ts +10 -0
  42. package/packages/core/src/sqliteEventBus.ts +336 -0
  43. package/packages/core/src/types.ts +63 -0
  44. package/packages/daemon/package.json +11 -0
  45. package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
  46. package/packages/daemon/src/api/authRoutes.ts +344 -0
  47. package/packages/daemon/src/api/bodyLimit.ts +133 -0
  48. package/packages/daemon/src/api/envSetRoutes.ts +170 -0
  49. package/packages/daemon/src/api/gitRoutes.ts +409 -0
  50. package/packages/daemon/src/api/hooks.ts +588 -0
  51. package/packages/daemon/src/api/inputPolicy.ts +249 -0
  52. package/packages/daemon/src/api/notificationRoutes.ts +532 -0
  53. package/packages/daemon/src/api/policyRoutes.ts +234 -0
  54. package/packages/daemon/src/api/policySetRoutes.ts +445 -0
  55. package/packages/daemon/src/api/routeUtils.ts +28 -0
  56. package/packages/daemon/src/api/routes.ts +1004 -0
  57. package/packages/daemon/src/api/server.ts +1388 -0
  58. package/packages/daemon/src/api/settingsRoutes.ts +367 -0
  59. package/packages/daemon/src/api/sqliteErrors.ts +47 -0
  60. package/packages/daemon/src/api/stt.ts +143 -0
  61. package/packages/daemon/src/api/terminalRoutes.ts +200 -0
  62. package/packages/daemon/src/api/validation.ts +287 -0
  63. package/packages/daemon/src/api/validationRoutes.ts +174 -0
  64. package/packages/daemon/src/api/workflowRoutes.ts +567 -0
  65. package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
  66. package/packages/daemon/src/api/ws.ts +1588 -0
  67. package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
  68. package/packages/daemon/src/auth/authMiddleware.ts +305 -0
  69. package/packages/daemon/src/auth/authService.ts +496 -0
  70. package/packages/daemon/src/auth/rateLimiter.ts +137 -0
  71. package/packages/daemon/src/config/pricing.ts +79 -0
  72. package/packages/daemon/src/crypto/encryption.ts +196 -0
  73. package/packages/daemon/src/db/db.ts +2745 -0
  74. package/packages/daemon/src/db/index.ts +16 -0
  75. package/packages/daemon/src/db/migrations.ts +182 -0
  76. package/packages/daemon/src/db/policyDb.ts +349 -0
  77. package/packages/daemon/src/db/schema.sql +408 -0
  78. package/packages/daemon/src/db/workflowDb.ts +464 -0
  79. package/packages/daemon/src/git/gitUtils.ts +544 -0
  80. package/packages/daemon/src/index.ts +6 -0
  81. package/packages/daemon/src/main.ts +525 -0
  82. package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
  83. package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
  84. package/packages/daemon/src/providers/registry.ts +111 -0
  85. package/packages/daemon/src/providers/types.ts +82 -0
  86. package/packages/daemon/src/sessions/auditLogger.ts +103 -0
  87. package/packages/daemon/src/sessions/policyEngine.ts +165 -0
  88. package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
  89. package/packages/daemon/src/sessions/policyTypes.ts +94 -0
  90. package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
  91. package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
  92. package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
  93. package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
  94. package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
  95. package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
  96. package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
  97. package/packages/daemon/src/workflows/contextManager.ts +83 -0
  98. package/packages/daemon/src/workflows/index.ts +31 -0
  99. package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
  100. package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
  101. package/packages/daemon/src/workflows/workflowParser.ts +217 -0
  102. package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
  103. package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
  104. package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
  105. package/packages/providers/claude-code/package.json +11 -0
  106. package/packages/providers/claude-code/src/index.ts +7 -0
  107. package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
  108. package/packages/providers/claude-code/src/provider.ts +311 -0
  109. package/packages/web/dist/android-chrome-192x192.png +0 -0
  110. package/packages/web/dist/android-chrome-512x512.png +0 -0
  111. package/packages/web/dist/apple-touch-icon.png +0 -0
  112. package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
  113. package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
  114. package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
  115. package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
  116. package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
  117. package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
  118. package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
  119. package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
  120. package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
  121. package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
  122. package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
  123. package/packages/web/dist/assets/index-hgphORiw.js +204 -0
  124. package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
  125. package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
  126. package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
  127. package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
  128. package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
  129. package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
  130. package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
  131. package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
  132. package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
  133. package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
  134. package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
  135. package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
  136. package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
  137. package/packages/web/dist/favicon.ico +0 -0
  138. package/packages/web/dist/icon.svg +1 -0
  139. package/packages/web/dist/index.html +29 -0
  140. package/packages/web/dist/manifest.json +29 -0
  141. package/packages/web/dist/og-image.png +0 -0
  142. package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
  143. package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
  144. package/packages/web/dist/originals/apple-touch-icon.png +0 -0
  145. package/packages/web/dist/originals/favicon.ico +0 -0
  146. package/packages/web/dist/piper.svg +1 -0
  147. package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
  148. package/packages/web/dist/sw.js +257 -0
  149. package/scripts/postinstall-link-workspaces.mjs +58 -0
@@ -0,0 +1,1770 @@
1
+ /**
2
+ * SessionManager - manages lifecycle of all sessions
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import * as os from "node:os";
7
+ import * as path from "node:path";
8
+ import { stripVTControlCharacters } from "node:util";
9
+ import type {
10
+ BillingMode,
11
+ EventBus,
12
+ ProviderId,
13
+ SessionHandle,
14
+ SessionStatus,
15
+ } from "@codepiper/core";
16
+ import { SessionNotFoundError } from "@codepiper/core";
17
+ import type { WebSocketManager } from "../api/ws";
18
+ import type { Database } from "../db/db";
19
+ import { GitUtils } from "../git/gitUtils";
20
+ import { resolveCodexAppServerSpikeState } from "../providers/codexAppServerScaffold";
21
+ import { getProviderDefinition } from "../providers/registry";
22
+ import type { ProviderCapabilities, ProviderResumeTarget } from "../providers/types";
23
+ import { PTYProcess } from "./ptyProcess";
24
+ import {
25
+ type TerminalCursor,
26
+ type TerminalInfo,
27
+ type TerminalMode,
28
+ TmuxSession,
29
+ } from "./tmuxSession";
30
+ import { TranscriptTailer } from "./transcriptTailer";
31
+
32
+ export interface CreateSessionOptions {
33
+ provider: ProviderId;
34
+ cwd: string;
35
+ env?: Record<string, string>;
36
+ args?: string[];
37
+ billingMode?: BillingMode;
38
+ dangerousMode?: boolean;
39
+ envSetIds?: string[];
40
+ providerResume?: ProviderResumeTarget;
41
+ /**
42
+ * Internal: preserve a stable CodePiper session id (used by /sessions/:id/resume).
43
+ */
44
+ sessionIdOverride?: string;
45
+ /**
46
+ * Internal: when true, update existing DB row instead of inserting a new one.
47
+ */
48
+ reuseExistingRecord?: boolean;
49
+ worktree?: {
50
+ branch: string;
51
+ createBranch: boolean;
52
+ startPoint?: string;
53
+ };
54
+ }
55
+
56
+ interface LaunchMetadata {
57
+ args: string[];
58
+ billingMode: BillingMode;
59
+ envSetIds: string[];
60
+ }
61
+
62
+ interface SessionMetadataShape extends Record<string, unknown> {
63
+ launch?: LaunchMetadata;
64
+ providerSession?: {
65
+ id?: string;
66
+ mode?: "resume" | "fork";
67
+ source?: string;
68
+ };
69
+ ui?: Record<string, unknown>;
70
+ security?: {
71
+ dangerousMode?: boolean;
72
+ codexHostAccessProfileEnabled?: boolean;
73
+ };
74
+ experimental?: Record<string, unknown>;
75
+ worktree?: Record<string, unknown>;
76
+ }
77
+
78
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
79
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
80
+ }
81
+
82
+ // Union type for session processes (PTY or Tmux)
83
+ type SessionProcess = PTYProcess | TmuxSession;
84
+
85
+ interface ManagedSession extends SessionHandle {
86
+ process: SessionProcess;
87
+ }
88
+
89
+ interface TailerContext {
90
+ db: Database;
91
+ transcriptPath: string;
92
+ }
93
+
94
+ const PTY_SPECIAL_KEY_SEQUENCES: Record<string, string> = {
95
+ enter: "\r",
96
+ escape: "\x1b",
97
+ esc: "\x1b",
98
+ tab: "\t",
99
+ "shift+tab": "\x1b[Z",
100
+ backspace: "\x7f",
101
+ delete: "\x1b[3~",
102
+ insert: "\x1b[2~",
103
+ home: "\x1b[H",
104
+ end: "\x1b[F",
105
+ pageup: "\x1b[5~",
106
+ pagedown: "\x1b[6~",
107
+ up: "\x1b[A",
108
+ down: "\x1b[B",
109
+ right: "\x1b[C",
110
+ left: "\x1b[D",
111
+ f1: "\x1bOP",
112
+ f2: "\x1bOQ",
113
+ f3: "\x1bOR",
114
+ f4: "\x1bOS",
115
+ f5: "\x1b[15~",
116
+ f6: "\x1b[17~",
117
+ f7: "\x1b[18~",
118
+ f8: "\x1b[19~",
119
+ f9: "\x1b[20~",
120
+ f10: "\x1b[21~",
121
+ f11: "\x1b[23~",
122
+ f12: "\x1b[24~",
123
+ };
124
+
125
+ const TMUX_SPECIAL_KEY_MAP: Record<string, string> = {
126
+ enter: "Enter",
127
+ escape: "Escape",
128
+ esc: "Escape",
129
+ tab: "Tab",
130
+ "shift+tab": "BTab",
131
+ backspace: "BSpace",
132
+ delete: "DC",
133
+ insert: "IC",
134
+ up: "Up",
135
+ down: "Down",
136
+ left: "Left",
137
+ right: "Right",
138
+ home: "Home",
139
+ end: "End",
140
+ pageup: "PageUp",
141
+ pagedown: "PageDown",
142
+ f1: "F1",
143
+ f2: "F2",
144
+ f3: "F3",
145
+ f4: "F4",
146
+ f5: "F5",
147
+ f6: "F6",
148
+ f7: "F7",
149
+ f8: "F8",
150
+ f9: "F9",
151
+ f10: "F10",
152
+ f11: "F11",
153
+ f12: "F12",
154
+ };
155
+
156
+ function resolveCtrlSequence(token: string): string | null {
157
+ if (/^[a-z]$/.test(token)) {
158
+ return String.fromCharCode(token.charCodeAt(0) - 96);
159
+ }
160
+
161
+ const ctrlSymbolMap: Record<string, string> = {
162
+ "@": "\x00",
163
+ space: "\x00",
164
+ "2": "\x00",
165
+ "[": "\x1b",
166
+ "3": "\x1b",
167
+ "\\": "\x1c",
168
+ "4": "\x1c",
169
+ "]": "\x1d",
170
+ "5": "\x1d",
171
+ "^": "\x1e",
172
+ "6": "\x1e",
173
+ _: "\x1f",
174
+ "-": "\x1f",
175
+ "/": "\x1f",
176
+ "7": "\x1f",
177
+ "?": "\x7f",
178
+ "8": "\x7f",
179
+ };
180
+
181
+ return ctrlSymbolMap[token] ?? null;
182
+ }
183
+
184
+ function resolveAltSequence(token: string): string | null {
185
+ if (token === "space") {
186
+ return " ";
187
+ }
188
+ if (token.length === 1) {
189
+ return token;
190
+ }
191
+ return null;
192
+ }
193
+
194
+ /** Maximum number of concurrent active sessions to prevent resource exhaustion */
195
+ const MAX_CONCURRENT_SESSIONS = 50;
196
+
197
+ /**
198
+ * Safe subset of daemon environment keys to forward to sessions.
199
+ * Only basic system variables are included to prevent leaking secrets
200
+ * (e.g. AWS keys, DB URLs) from the daemon process to Claude Code.
201
+ */
202
+ const SAFE_INHERITED_ENV_KEYS = new Set([
203
+ "PATH",
204
+ "HOME",
205
+ "USER",
206
+ "SHELL",
207
+ "TERM",
208
+ "LANG",
209
+ "LC_ALL",
210
+ "LC_CTYPE",
211
+ "TMPDIR",
212
+ "TZ",
213
+ "EDITOR",
214
+ "VISUAL",
215
+ "XDG_CONFIG_HOME",
216
+ "XDG_DATA_HOME",
217
+ "XDG_RUNTIME_DIR",
218
+ "XDG_CACHE_HOME",
219
+ ]);
220
+
221
+ const CODEX_SESSION_BANNER_REGEX =
222
+ /\bcodex session\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i;
223
+ const CODEX_SESSION_BANNER_BUFFER_BYTES = 512;
224
+
225
+ function generateEphemeralHookSecret(): string {
226
+ const bytes = new Uint8Array(32);
227
+ crypto.getRandomValues(bytes);
228
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
229
+ }
230
+
231
+ function ensurePrivateDirectory(dir: string): void {
232
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
233
+ try {
234
+ fs.chmodSync(dir, 0o700);
235
+ } catch {
236
+ // best-effort on non-POSIX filesystems
237
+ }
238
+ }
239
+
240
+ function getHomeDir(): string {
241
+ return process.env.HOME || os.homedir();
242
+ }
243
+
244
+ export function isDangerousModeMetadata(metadata: unknown): boolean {
245
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
246
+ return false;
247
+ }
248
+
249
+ const security = (metadata as Record<string, unknown>).security;
250
+ if (!security || typeof security !== "object" || Array.isArray(security)) {
251
+ return false;
252
+ }
253
+
254
+ return (security as Record<string, unknown>).dangerousMode === true;
255
+ }
256
+
257
+ export class SessionManager {
258
+ private sessions = new Map<string, ManagedSession>();
259
+ private tailers = new Map<string, TranscriptTailer>();
260
+ private saveOffsetIntervals = new Map<string, Timer>();
261
+ private tailerContexts = new Map<string, TailerContext>();
262
+ private inputQueueBySession = new Map<string, Promise<void>>();
263
+ private codexBannerBufferBySession = new Map<string, string>();
264
+ private db: Database;
265
+ private eventBus: EventBus;
266
+ private wsManager?: WebSocketManager;
267
+
268
+ /**
269
+ * Root directory for per-session runtime files.
270
+ */
271
+ getSessionsRootDir(): string {
272
+ return `${getHomeDir()}/.codepiper/sessions`;
273
+ }
274
+
275
+ /**
276
+ * Get the image upload directory for a session.
277
+ * Images uploaded via the web dashboard are saved here and referenced by file path.
278
+ */
279
+ getSessionRuntimeDir(sessionId: string): string {
280
+ return `${this.getSessionsRootDir()}/${sessionId}`;
281
+ }
282
+
283
+ /**
284
+ * Get the image upload directory for a session.
285
+ * Images uploaded via the web dashboard are saved here and referenced by file path.
286
+ */
287
+ getImageDir(sessionId: string): string {
288
+ return `${this.getSessionRuntimeDir(sessionId)}/images`;
289
+ }
290
+
291
+ constructor(db: Database, eventBus: EventBus) {
292
+ this.db = db;
293
+ this.eventBus = eventBus;
294
+ }
295
+
296
+ /**
297
+ * Set WebSocketManager for PTY output broadcasting.
298
+ * Called after daemon initialization to wire up WebSocket streaming.
299
+ */
300
+ setWebSocketManager(wsManager: WebSocketManager): void {
301
+ this.wsManager = wsManager;
302
+ }
303
+
304
+ getProviderCapabilities(providerId: ProviderId): ProviderCapabilities {
305
+ return getProviderDefinition(providerId).capabilities;
306
+ }
307
+
308
+ isSessionDangerousMode(sessionId: string): boolean {
309
+ const session = this.getSession(sessionId);
310
+ return isDangerousModeMetadata(session?.metadata);
311
+ }
312
+
313
+ async createSession(options: CreateSessionOptions): Promise<SessionHandle> {
314
+ const {
315
+ provider,
316
+ cwd,
317
+ env = {},
318
+ args = [],
319
+ providerResume,
320
+ sessionIdOverride,
321
+ reuseExistingRecord,
322
+ } = options;
323
+ const dangerousMode = options.dangerousMode === true;
324
+ const providerDefinition = getProviderDefinition(provider);
325
+
326
+ // Guard against resource exhaustion
327
+ if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
328
+ throw new Error(
329
+ `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Stop an existing session first.`
330
+ );
331
+ }
332
+
333
+ if (!fs.existsSync(cwd)) {
334
+ throw new Error(`Working directory does not exist: ${cwd}`);
335
+ }
336
+
337
+ const sessionId = sessionIdOverride ?? crypto.randomUUID();
338
+ const existingDbSession = reuseExistingRecord ? this.db.getSession(sessionId) : undefined;
339
+ if (reuseExistingRecord && !existingDbSession) {
340
+ throw new SessionNotFoundError(sessionId);
341
+ }
342
+
343
+ const billingMode = options.billingMode ?? "subscription";
344
+ const daemonSettings = this.db.getDaemonSettings();
345
+ const codexAppServerSpike =
346
+ provider === "codex"
347
+ ? resolveCodexAppServerSpikeState({
348
+ terminalFeatures: daemonSettings.terminalFeatures,
349
+ })
350
+ : undefined;
351
+
352
+ // Start with safe subset of process.env, then merge user-provided env
353
+ const sessionEnv: Record<string, string> = {};
354
+ for (const key of SAFE_INHERITED_ENV_KEYS) {
355
+ const value = process.env[key];
356
+ if (value !== undefined) {
357
+ sessionEnv[key] = value;
358
+ }
359
+ }
360
+
361
+ // Optional SSH agent forwarding for git operations inside tmux sessions.
362
+ if (daemonSettings.forwardSshAuthSock) {
363
+ const sshAuthSock = process.env.SSH_AUTH_SOCK;
364
+ if (sshAuthSock) {
365
+ sessionEnv.SSH_AUTH_SOCK = sshAuthSock;
366
+ }
367
+ const sshAgentPid = process.env.SSH_AGENT_PID;
368
+ if (sshAgentPid) {
369
+ sessionEnv.SSH_AGENT_PID = sshAgentPid;
370
+ }
371
+ }
372
+
373
+ // Merge user-provided env (overrides safe defaults)
374
+ for (const [key, value] of Object.entries(env)) {
375
+ if (value !== undefined) {
376
+ sessionEnv[key] = value;
377
+ }
378
+ }
379
+
380
+ // Merge env sets if provided (later sets override on key collision)
381
+ // NOTE: This must happen BEFORE billing-mode scrubbing so env sets
382
+ // cannot re-inject ANTHROPIC_API_KEY in subscription mode.
383
+ if (options.envSetIds && options.envSetIds.length > 0) {
384
+ for (const envSetId of options.envSetIds) {
385
+ const vars = this.db.decryptEnvSetVars(envSetId);
386
+ Object.assign(sessionEnv, vars);
387
+ }
388
+ }
389
+
390
+ // Handle API key based on billing mode (after env set merge)
391
+ if (billingMode === "api") {
392
+ // In API mode, preserve ANTHROPIC_API_KEY (from user env or process.env)
393
+ if (!sessionEnv.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY) {
394
+ sessionEnv.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
395
+ }
396
+ if (!sessionEnv.ANTHROPIC_API_KEY) {
397
+ console.warn(
398
+ `[Session ${sessionId}] billingMode is "api" but ANTHROPIC_API_KEY is not set`
399
+ );
400
+ }
401
+ } else {
402
+ // In subscription mode, scrub API key (env sets cannot override this)
403
+ delete sessionEnv.ANTHROPIC_API_KEY;
404
+ }
405
+
406
+ // Always scrub — controls session nesting, not billing
407
+ delete sessionEnv.CLAUDECODE;
408
+
409
+ if (codexAppServerSpike?.enrolled) {
410
+ // Scaffold marker for future app-server adapter wiring.
411
+ sessionEnv.CODEPIPER_CODEX_APP_SERVER_SPIKE = "1";
412
+ }
413
+
414
+ // Handle worktree creation if requested
415
+ let effectiveCwd = cwd;
416
+ let worktreeMetadata: Record<string, any> | undefined;
417
+
418
+ if (options.worktree) {
419
+ const { branch, createBranch, startPoint } = options.worktree;
420
+
421
+ const isRepo = await GitUtils.isGitRepo(cwd);
422
+ if (!isRepo) {
423
+ throw new Error(`Not a git repository: ${cwd}`);
424
+ }
425
+
426
+ const repoRoot = await GitUtils.getRepoRoot(cwd);
427
+ const worktreePath = GitUtils.getWorktreePath(repoRoot, sessionId);
428
+
429
+ // Check if branch is already checked out elsewhere
430
+ if (!createBranch) {
431
+ const branchCheck = await GitUtils.isBranchCheckedOut(repoRoot, branch);
432
+ if (branchCheck.checkedOut) {
433
+ throw new Error(
434
+ `Branch '${branch}' is already checked out${branchCheck.worktreePath ? ` in ${branchCheck.worktreePath}` : ""}`
435
+ );
436
+ }
437
+ }
438
+
439
+ await GitUtils.createWorktree({
440
+ repoPath: repoRoot,
441
+ worktreePath,
442
+ branch,
443
+ createBranch,
444
+ startPoint,
445
+ });
446
+
447
+ effectiveCwd = worktreePath;
448
+ worktreeMetadata = {
449
+ repoRoot,
450
+ worktreePath,
451
+ branch,
452
+ createdBranch: createBranch,
453
+ originalCwd: cwd,
454
+ };
455
+
456
+ if (provider === "claude-code") {
457
+ // Pre-create Claude project directory so the workspace trust prompt is skipped.
458
+ // Claude Code checks ~/.claude/projects/<encoded-path>/ to determine trust.
459
+ const encodedPath = worktreePath.replace(/\//g, "-");
460
+ const projectDir = `${process.env.HOME}/.claude/projects/${encodedPath}`;
461
+ if (!fs.existsSync(projectDir)) {
462
+ fs.mkdirSync(projectDir, { recursive: true });
463
+ }
464
+ }
465
+ }
466
+
467
+ // Create image upload directory for this session
468
+ ensurePrivateDirectory(this.getSessionsRootDir());
469
+ const runtimeDir = this.getSessionRuntimeDir(sessionId);
470
+ const imageDir = this.getImageDir(sessionId);
471
+ ensurePrivateDirectory(runtimeDir);
472
+ ensurePrivateDirectory(imageDir);
473
+
474
+ let settingsPath: string | undefined;
475
+ if (providerDefinition.prepareSession) {
476
+ const socketPath = process.env.CODEPIPER_UNIX_SOCK || "/tmp/codepiper.sock";
477
+ let secret = process.env.CODEPIPER_SECRET;
478
+ if (!secret) {
479
+ secret = generateEphemeralHookSecret();
480
+ process.env.CODEPIPER_SECRET = secret;
481
+ if (!(process.env.NODE_ENV === "test" || process.env.BUN_TEST === "1")) {
482
+ console.warn(
483
+ "[security] CODEPIPER_SECRET was missing during session creation. Generated an ephemeral secret for this daemon process."
484
+ );
485
+ }
486
+ }
487
+
488
+ const preparation = await providerDefinition.prepareSession({
489
+ sessionId,
490
+ runtimeDir,
491
+ socketPath,
492
+ secret,
493
+ });
494
+ settingsPath = preparation.settingsPath;
495
+ }
496
+
497
+ const command = providerDefinition.buildCommand({
498
+ sessionId,
499
+ settingsPath,
500
+ providerArgs: args,
501
+ dangerousMode,
502
+ providerResume,
503
+ codexHostAccessProfileEnabled: daemonSettings.codexHostAccessProfileEnabled,
504
+ terminalFeatures: daemonSettings.terminalFeatures,
505
+ });
506
+
507
+ // Use provider-configured runtime process.
508
+ let sessionProcess: SessionProcess;
509
+
510
+ if (providerDefinition.runtime === "tmux") {
511
+ const outputLogPath = `${runtimeDir}/output.log`;
512
+
513
+ sessionProcess = new TmuxSession({
514
+ sessionName: `codepiper-${sessionId}`,
515
+ command,
516
+ cwd: effectiveCwd,
517
+ env: sessionEnv,
518
+ cols: 120,
519
+ rows: 30,
520
+ outputLogPath,
521
+ onData: (data, cursor) => this.handlePtyData(sessionId, data, cursor),
522
+ onExit: (exitCode, signal) => this.handlePtyExit(sessionId, exitCode, signal),
523
+ onModeChange: (mode) => this.handleModeChange(sessionId, mode),
524
+ });
525
+ await sessionProcess.create();
526
+
527
+ // Auto-accept workspace trust prompt for worktree sessions.
528
+ // New worktree directories trigger Claude Code's "Do you trust this folder?" prompt.
529
+ // Since we created the worktree ourselves, auto-accept by sending Enter after a delay.
530
+ if (worktreeMetadata) {
531
+ setTimeout(async () => {
532
+ try {
533
+ await (sessionProcess as TmuxSession).sendKey("Enter");
534
+ } catch {
535
+ // Session may have already exited
536
+ }
537
+ }, 2000);
538
+ }
539
+ } else {
540
+ sessionProcess = new PTYProcess({
541
+ command,
542
+ cwd: effectiveCwd,
543
+ env: sessionEnv,
544
+ cols: 120,
545
+ rows: 30,
546
+ onData: (data) => this.handlePtyData(sessionId, data),
547
+ onExit: (exitCode, signal) => this.handlePtyExit(sessionId, exitCode, signal),
548
+ });
549
+ }
550
+
551
+ const metadata: SessionMetadataShape = {};
552
+ metadata.launch = {
553
+ args,
554
+ billingMode,
555
+ envSetIds: options.envSetIds ?? [],
556
+ };
557
+ if (providerResume) {
558
+ metadata.providerSession = {
559
+ id: providerResume.providerSessionId,
560
+ mode: providerResume.mode ?? "resume",
561
+ source: "user-supplied",
562
+ };
563
+ } else if (provider === "claude-code") {
564
+ metadata.providerSession = {
565
+ id: sessionId,
566
+ mode: "resume",
567
+ source: "codepiper-session-id",
568
+ };
569
+ } else if (provider === "codex") {
570
+ metadata.providerSession = {
571
+ mode: "resume",
572
+ source: "codex-auto-detect-pending",
573
+ };
574
+ }
575
+ if (worktreeMetadata) {
576
+ metadata.worktree = worktreeMetadata;
577
+ }
578
+ if (dangerousMode) {
579
+ metadata.security = { dangerousMode: true };
580
+ }
581
+ if (provider === "codex" && daemonSettings.codexHostAccessProfileEnabled) {
582
+ metadata.security = {
583
+ ...(metadata.security ?? {}),
584
+ codexHostAccessProfileEnabled: true,
585
+ };
586
+ }
587
+ if (codexAppServerSpike?.configured) {
588
+ metadata.experimental = {
589
+ codexAppServerSpike: {
590
+ configured: codexAppServerSpike.configured,
591
+ enrolled: codexAppServerSpike.enrolled,
592
+ mode: codexAppServerSpike.mode,
593
+ },
594
+ };
595
+ }
596
+ const sessionMetadata = Object.keys(metadata).length > 0 ? metadata : undefined;
597
+
598
+ const session: ManagedSession = {
599
+ id: sessionId,
600
+ provider,
601
+ cwd: effectiveCwd,
602
+ status: "STARTING" as SessionStatus,
603
+ createdAt: existingDbSession?.createdAt ?? new Date(),
604
+ updatedAt: new Date(),
605
+ pid: sessionProcess.pid,
606
+ process: sessionProcess,
607
+ metadata: sessionMetadata,
608
+ };
609
+
610
+ this.sessions.set(sessionId, session);
611
+
612
+ // Persist session to database
613
+ if (reuseExistingRecord) {
614
+ this.db.updateSession(sessionId, {
615
+ status: "STARTING",
616
+ pid: sessionProcess.pid,
617
+ metadata: sessionMetadata,
618
+ });
619
+ } else {
620
+ this.db.createSession({
621
+ id: sessionId,
622
+ provider,
623
+ cwd: effectiveCwd,
624
+ status: "STARTING",
625
+ pid: sessionProcess.pid,
626
+ metadata: sessionMetadata,
627
+ });
628
+
629
+ // Auto-apply default policy set if one exists
630
+ const defaultSet = this.db.getDefaultPolicySet();
631
+ if (defaultSet) {
632
+ this.db.applyPolicySetToSession(sessionId, defaultSet.id);
633
+ }
634
+ }
635
+
636
+ // Broadcast session state change to WebSocket subscribers
637
+ const handle = this.toHandle(session);
638
+ if (this.wsManager) {
639
+ this.wsManager.broadcastSessionChange(
640
+ reuseExistingRecord
641
+ ? ({
642
+ type: "session_updated",
643
+ session: handle,
644
+ } as any)
645
+ : ({
646
+ type: "session_created",
647
+ session: handle,
648
+ } as any)
649
+ );
650
+ }
651
+
652
+ return handle;
653
+ }
654
+
655
+ getSession(sessionId: string): SessionHandle | undefined {
656
+ const session = this.sessions.get(sessionId);
657
+ if (!session) {
658
+ return undefined;
659
+ }
660
+ return this.toHandle(session);
661
+ }
662
+
663
+ /**
664
+ * Register a session for testing or external session management.
665
+ */
666
+ registerSession(handle: SessionHandle, process: SessionProcess): void {
667
+ this.sessions.set(handle.id, { ...handle, process });
668
+ }
669
+
670
+ /**
671
+ * Re-adopt an orphaned session whose tmux is still alive but not tracked in memory.
672
+ * Used on daemon restart and via the POST /sessions/:id/recover endpoint.
673
+ */
674
+ async recoverSession(sessionId: string): Promise<SessionHandle> {
675
+ return this.adoptSession(sessionId);
676
+ }
677
+
678
+ /**
679
+ * Reopen a closed session by delegating to provider-native resume behavior.
680
+ * This requires a persisted provider session id in metadata.
681
+ */
682
+ async resumeSession(sessionId: string): Promise<SessionHandle> {
683
+ const existing = this.sessions.get(sessionId);
684
+ if (existing) {
685
+ return this.toHandle(existing);
686
+ }
687
+
688
+ const dbSession = this.db.getSession(sessionId);
689
+ if (!dbSession) {
690
+ throw new SessionNotFoundError(sessionId);
691
+ }
692
+
693
+ const tmuxName = `codepiper-${sessionId}`;
694
+ const check = Bun.spawnSync(["tmux", "has-session", "-t", tmuxName], {
695
+ stdout: "ignore",
696
+ stderr: "ignore",
697
+ });
698
+ if (check.exitCode === 0) {
699
+ throw new Error(
700
+ `Session ${sessionId} still has a live tmux runtime. Use recover to re-adopt it.`
701
+ );
702
+ }
703
+
704
+ const metadata = (dbSession.metadata ?? {}) as SessionMetadataShape;
705
+ const launch = metadata.launch;
706
+
707
+ let providerSessionId = metadata.providerSession?.id;
708
+ if (!(providerSessionId && providerSessionId.trim().length > 0)) {
709
+ if (dbSession.provider === "claude-code") {
710
+ providerSessionId = sessionId;
711
+ } else if (dbSession.provider === "codex") {
712
+ providerSessionId = await this.detectCodexProviderSessionId(dbSession.cwd, {
713
+ timeoutMs: 1500,
714
+ cwdTimestampFloor: dbSession.updatedAt.getTime(),
715
+ });
716
+ }
717
+ }
718
+
719
+ if (!(providerSessionId && providerSessionId.trim().length > 0)) {
720
+ throw new Error(
721
+ `Provider session id is unavailable for ${dbSession.provider}. Start a new session and set "Resume by Provider Session ID" manually.`
722
+ );
723
+ }
724
+
725
+ return await this.createSession({
726
+ provider: dbSession.provider,
727
+ cwd: dbSession.cwd,
728
+ args: launch?.args ?? [],
729
+ billingMode: launch?.billingMode ?? "subscription",
730
+ dangerousMode: isDangerousModeMetadata(metadata),
731
+ envSetIds: launch?.envSetIds ?? [],
732
+ providerResume: {
733
+ providerSessionId,
734
+ mode: "resume",
735
+ },
736
+ sessionIdOverride: sessionId,
737
+ reuseExistingRecord: true,
738
+ });
739
+ }
740
+
741
+ async adoptSession(sessionId: string): Promise<SessionHandle> {
742
+ // Already managed — return existing handle
743
+ const existing = this.sessions.get(sessionId);
744
+ if (existing) {
745
+ return this.toHandle(existing);
746
+ }
747
+
748
+ // Load from DB
749
+ const dbSession = this.db.getSession(sessionId);
750
+ if (!dbSession) {
751
+ throw new SessionNotFoundError(sessionId);
752
+ }
753
+
754
+ const providerDefinition = getProviderDefinition(dbSession.provider);
755
+ if (
756
+ providerDefinition.runtime !== "tmux" ||
757
+ !providerDefinition.capabilities.supportsTmuxAdoption
758
+ ) {
759
+ throw new Error(
760
+ `Cannot adopt provider ${dbSession.provider} (runtime: ${providerDefinition.runtime})`
761
+ );
762
+ }
763
+
764
+ // Verify tmux session actually exists
765
+ const tmuxName = `codepiper-${sessionId}`;
766
+ const check = Bun.spawnSync(["tmux", "has-session", "-t", tmuxName], {
767
+ stdout: "ignore",
768
+ stderr: "ignore",
769
+ });
770
+ if (check.exitCode !== 0) {
771
+ // Tmux is gone — mark STOPPED and throw
772
+ this.db.updateSession(sessionId, { status: "STOPPED" });
773
+ throw new Error(`Tmux session ${tmuxName} is no longer running`);
774
+ }
775
+
776
+ // Create TmuxSession that attaches to the existing tmux
777
+ const sessionProcess = new TmuxSession({
778
+ sessionName: tmuxName,
779
+ command: [], // Not needed — session already running
780
+ cwd: dbSession.cwd,
781
+ env: {},
782
+ cols: dbSession.ptyCols || 120,
783
+ rows: dbSession.ptyRows || 30,
784
+ onData: (data, cursor) => this.handlePtyData(sessionId, data, cursor),
785
+ onExit: (exitCode, signal) => this.handlePtyExit(sessionId, exitCode, signal),
786
+ onModeChange: (mode) => this.handleModeChange(sessionId, mode),
787
+ });
788
+
789
+ // Adopt: start polling + exit monitoring without creating new tmux session
790
+ await sessionProcess.adopt();
791
+
792
+ const session: ManagedSession = {
793
+ id: sessionId,
794
+ provider: dbSession.provider,
795
+ cwd: dbSession.cwd,
796
+ status: "RUNNING" as SessionStatus,
797
+ createdAt: dbSession.createdAt,
798
+ updatedAt: new Date(),
799
+ pid: sessionProcess.pid,
800
+ process: sessionProcess,
801
+ transcriptPath: dbSession.transcriptPath,
802
+ metadata: dbSession.metadata,
803
+ };
804
+
805
+ this.sessions.set(sessionId, session);
806
+ this.db.updateSession(sessionId, { status: "RUNNING" });
807
+
808
+ // Broadcast status update
809
+ if (this.wsManager) {
810
+ this.wsManager.broadcastSessionChange({
811
+ type: "session_updated",
812
+ session: this.toHandle(session),
813
+ } as any);
814
+ }
815
+
816
+ // Resume transcript tailer if path is known
817
+ if (dbSession.transcriptPath) {
818
+ try {
819
+ await this.startTranscriptTailer(
820
+ sessionId,
821
+ dbSession.transcriptPath,
822
+ this.db,
823
+ this.eventBus
824
+ );
825
+ } catch {
826
+ // Transcript resumption is best-effort
827
+ }
828
+ }
829
+
830
+ console.log(`Adopted orphaned session ${sessionId} (tmux: ${tmuxName})`);
831
+ return this.toHandle(session);
832
+ }
833
+
834
+ listSessions(): SessionHandle[] {
835
+ return Array.from(this.sessions.values()).map((s) => this.toHandle(s));
836
+ }
837
+
838
+ async getSessionOutput(sessionId: string): Promise<string> {
839
+ const session = this.requireSession(sessionId);
840
+ if (session.process instanceof TmuxSession) {
841
+ session.process.ensurePolling();
842
+ return await session.process.captureVisiblePane();
843
+ }
844
+ return "";
845
+ }
846
+
847
+ async resizeSession(sessionId: string, cols: number, rows: number): Promise<void> {
848
+ const session = this.requireSession(sessionId);
849
+ if ("resize" in session.process && typeof session.process.resize === "function") {
850
+ await session.process.resize(cols, rows);
851
+ }
852
+ }
853
+
854
+ async stopSession(sessionId: string): Promise<void> {
855
+ const session = this.requireSession(sessionId);
856
+
857
+ await this.stopTranscriptTailer(sessionId);
858
+
859
+ if (!session.process.closed) {
860
+ try {
861
+ session.process.write("\x04"); // Ctrl+D for graceful exit
862
+ await session.process.kill("SIGTERM");
863
+ } catch (err) {
864
+ console.error(`Error stopping session ${sessionId}:`, err);
865
+ // Try to force kill if graceful stop fails
866
+ try {
867
+ await session.process.kill("SIGKILL");
868
+ } catch (killErr) {
869
+ console.error(`Force kill also failed for ${sessionId}:`, killErr);
870
+ }
871
+ }
872
+ }
873
+
874
+ session.status = "STOPPED";
875
+ session.updatedAt = new Date();
876
+
877
+ // Worktree cleanup (graceful: stash + remove without force)
878
+ const worktreeInfo = session.metadata?.worktree as
879
+ | { repoRoot: string; worktreePath: string }
880
+ | undefined;
881
+ if (worktreeInfo) {
882
+ try {
883
+ await GitUtils.stashChanges(worktreeInfo.worktreePath, sessionId);
884
+ await GitUtils.removeWorktree(worktreeInfo.repoRoot, worktreeInfo.worktreePath, false);
885
+ } catch (err) {
886
+ console.warn(`Worktree cleanup failed for session ${sessionId}:`, err);
887
+ // Don't fail the stop — worktree can be cleaned up manually
888
+ }
889
+ }
890
+
891
+ // Clean up image upload directory
892
+ this.cleanupSessionSecretFiles(sessionId);
893
+ this.cleanupImageDir(sessionId);
894
+
895
+ // Persist status change to database
896
+ this.db.updateSession(sessionId, {
897
+ status: "STOPPED",
898
+ });
899
+
900
+ this.wsManager?.clearPtySequence(sessionId);
901
+ this.inputQueueBySession.delete(sessionId);
902
+ this.codexBannerBufferBySession.delete(sessionId);
903
+
904
+ // Remove from in-memory map to prevent memory leak
905
+ this.sessions.delete(sessionId);
906
+ }
907
+
908
+ async killSession(sessionId: string): Promise<void> {
909
+ const session = this.requireSession(sessionId);
910
+
911
+ await this.stopTranscriptTailer(sessionId);
912
+
913
+ if (!session.process.closed) {
914
+ try {
915
+ await session.process.kill("SIGKILL");
916
+ } catch (err) {
917
+ console.error(`Error killing session ${sessionId}:`, err);
918
+ throw err; // Propagate error for force kill failures
919
+ }
920
+ }
921
+
922
+ session.status = "STOPPED";
923
+ session.updatedAt = new Date();
924
+
925
+ // Worktree cleanup (force: remove without stashing)
926
+ const worktreeInfo = session.metadata?.worktree as
927
+ | { repoRoot: string; worktreePath: string }
928
+ | undefined;
929
+ if (worktreeInfo) {
930
+ try {
931
+ await GitUtils.removeWorktree(worktreeInfo.repoRoot, worktreeInfo.worktreePath, true);
932
+ } catch (err) {
933
+ console.error(`Force worktree removal failed for session ${sessionId}:`, err);
934
+ }
935
+ }
936
+
937
+ // Clean up image upload directory
938
+ this.cleanupSessionSecretFiles(sessionId);
939
+ this.cleanupImageDir(sessionId);
940
+
941
+ // Persist status change to database
942
+ this.db.updateSession(sessionId, {
943
+ status: "STOPPED",
944
+ });
945
+
946
+ // Broadcast session update to WebSocket subscribers
947
+ if (this.wsManager) {
948
+ this.wsManager.broadcastSessionChange({
949
+ type: "session_updated",
950
+ session: { ...this.toHandle(session), status: "STOPPED" },
951
+ } as any);
952
+ }
953
+
954
+ this.wsManager?.clearPtySequence(sessionId);
955
+ this.inputQueueBySession.delete(sessionId);
956
+ this.codexBannerBufferBySession.delete(sessionId);
957
+
958
+ // Remove from in-memory map to prevent memory leak
959
+ this.sessions.delete(sessionId);
960
+ }
961
+
962
+ async stopAll(): Promise<void> {
963
+ const stopPromises = Array.from(this.sessions.keys()).map(async (sessionId) => {
964
+ try {
965
+ await this.stopTranscriptTailer(sessionId);
966
+ await this.stopSession(sessionId);
967
+ } catch {
968
+ // Ignore errors, continue stopping others
969
+ }
970
+ });
971
+
972
+ await Promise.all(stopPromises);
973
+ }
974
+
975
+ /**
976
+ * Detach from all managed sessions without killing them.
977
+ * Stops tailers and saves transcript offsets, clears in-memory state,
978
+ * but leaves tmux sessions alive and DB status as RUNNING.
979
+ * Used for graceful daemon restart with session preservation.
980
+ */
981
+ async detachAll(): Promise<void> {
982
+ const detachPromises = Array.from(this.sessions.keys()).map(async (sessionId) => {
983
+ try {
984
+ await this.stopTranscriptTailer(sessionId);
985
+
986
+ const session = this.sessions.get(sessionId);
987
+ if (session?.process instanceof TmuxSession) {
988
+ session.process.detach();
989
+ }
990
+ } catch {
991
+ // Ignore errors, continue detaching others
992
+ }
993
+ });
994
+
995
+ await Promise.all(detachPromises);
996
+ if (this.wsManager) {
997
+ for (const sessionId of this.sessions.keys()) {
998
+ this.wsManager.clearPtySequence(sessionId);
999
+ }
1000
+ }
1001
+ this.inputQueueBySession.clear();
1002
+ this.sessions.clear();
1003
+ }
1004
+
1005
+ async sendText(sessionId: string, text: string): Promise<void> {
1006
+ await this.enqueueInputOperation(sessionId, async () => {
1007
+ const session = this.requireSession(sessionId);
1008
+
1009
+ if (session.process.closed) {
1010
+ throw new Error(`Session ${sessionId} is closed`);
1011
+ }
1012
+
1013
+ // Auto-exit scroll/search mode so typing goes to the live session
1014
+ await this.autoExitScrollMode(session);
1015
+
1016
+ try {
1017
+ session.process.write(text);
1018
+ session.updatedAt = new Date();
1019
+ } catch (error) {
1020
+ const errorMsg = error instanceof Error ? error.message : String(error);
1021
+ throw new Error(`Failed to send text to session ${sessionId}: ${errorMsg}`);
1022
+ }
1023
+ });
1024
+ }
1025
+
1026
+ flushWrites(sessionId: string): void {
1027
+ const session = this.requireSession(sessionId);
1028
+
1029
+ // Flush pending writes if the session supports it (TmuxSession)
1030
+ if ("flush" in session.process && typeof session.process.flush === "function") {
1031
+ session.process.flush();
1032
+ }
1033
+ }
1034
+
1035
+ async sendKeys(sessionId: string, keys: string[]): Promise<void> {
1036
+ await this.enqueueInputOperation(sessionId, async () => {
1037
+ const session = this.requireSession(sessionId);
1038
+
1039
+ if (session.process.closed) {
1040
+ throw new Error(`Session ${sessionId} is closed`);
1041
+ }
1042
+
1043
+ // Auto-exit scroll/search mode so keys go to the live session
1044
+ await this.autoExitScrollMode(session);
1045
+
1046
+ try {
1047
+ // For TmuxSession, use sendKey() for special keys
1048
+ if ("sendKey" in session.process && typeof session.process.sendKey === "function") {
1049
+ for (const key of keys) {
1050
+ const normalized = key.trim().toLowerCase();
1051
+ // Map common key names to tmux key names
1052
+ const tmuxKey = this.mapToTmuxKey(normalized);
1053
+ await session.process.sendKey(tmuxKey);
1054
+ }
1055
+ } else {
1056
+ // For PTYProcess, use write() with control sequences
1057
+ for (const key of keys) {
1058
+ const normalized = key.trim().toLowerCase();
1059
+ session.process.write(this.mapToPtyKeySequence(normalized));
1060
+ }
1061
+ }
1062
+ session.updatedAt = new Date();
1063
+ } catch (error) {
1064
+ const errorMsg = error instanceof Error ? error.message : String(error);
1065
+ throw new Error(`Failed to send keys to session ${sessionId}: ${errorMsg}`);
1066
+ }
1067
+ });
1068
+ }
1069
+
1070
+ private enqueueInputOperation(sessionId: string, operation: () => Promise<void>): Promise<void> {
1071
+ const current = this.inputQueueBySession.get(sessionId) ?? Promise.resolve();
1072
+ const next = current.catch(() => undefined).then(operation);
1073
+
1074
+ const tracked = next.finally(() => {
1075
+ if (this.inputQueueBySession.get(sessionId) === tracked) {
1076
+ this.inputQueueBySession.delete(sessionId);
1077
+ }
1078
+ });
1079
+
1080
+ this.inputQueueBySession.set(sessionId, tracked);
1081
+ return tracked;
1082
+ }
1083
+
1084
+ private mapToTmuxKey(key: string): string {
1085
+ const special = TMUX_SPECIAL_KEY_MAP[key];
1086
+ if (special) {
1087
+ return special;
1088
+ }
1089
+
1090
+ if (key.startsWith("ctrl+")) {
1091
+ return this.mapModifierToTmuxKey("C", key.slice(5));
1092
+ }
1093
+ if (key.startsWith("alt+")) {
1094
+ return this.mapModifierToTmuxKey("M", key.slice(4));
1095
+ }
1096
+
1097
+ return key;
1098
+ }
1099
+
1100
+ private mapModifierToTmuxKey(prefix: "C" | "M", rawToken: string): string {
1101
+ const token = rawToken.trim().toLowerCase();
1102
+ if (!token) {
1103
+ return `${prefix}-${rawToken}`;
1104
+ }
1105
+
1106
+ if (token === "space") {
1107
+ return `${prefix}-Space`;
1108
+ }
1109
+ if (token.length === 1) {
1110
+ return `${prefix}-${token}`;
1111
+ }
1112
+
1113
+ const named = TMUX_SPECIAL_KEY_MAP[token];
1114
+ if (named) {
1115
+ return `${prefix}-${named}`;
1116
+ }
1117
+
1118
+ return `${prefix}-${rawToken}`;
1119
+ }
1120
+
1121
+ private mapToPtyKeySequence(key: string): string {
1122
+ const special = PTY_SPECIAL_KEY_SEQUENCES[key];
1123
+ if (special) {
1124
+ return special;
1125
+ }
1126
+
1127
+ if (key.startsWith("ctrl+")) {
1128
+ const ctrlToken = key.slice(5).trim().toLowerCase();
1129
+ const ctrlSequence = resolveCtrlSequence(ctrlToken);
1130
+ if (ctrlSequence) {
1131
+ return ctrlSequence;
1132
+ }
1133
+ }
1134
+
1135
+ if (key.startsWith("alt+")) {
1136
+ const altToken = key.slice(4).trim().toLowerCase();
1137
+ const altSequence = resolveAltSequence(altToken);
1138
+ if (altSequence) {
1139
+ return `\x1b${altSequence}`;
1140
+ }
1141
+ }
1142
+
1143
+ return key;
1144
+ }
1145
+
1146
+ // --- Terminal mode methods (delegated to TmuxSession) ---
1147
+
1148
+ private requireTmuxSession(sessionId: string): TmuxSession {
1149
+ const session = this.requireSession(sessionId);
1150
+ if (!(session.process instanceof TmuxSession)) {
1151
+ throw new Error("Terminal modes are only supported for tmux sessions");
1152
+ }
1153
+ return session.process;
1154
+ }
1155
+
1156
+ private async autoExitScrollMode(session: ManagedSession): Promise<void> {
1157
+ if (session.process instanceof TmuxSession && session.process.mode !== "interactive") {
1158
+ await session.process.exitScrollMode();
1159
+ }
1160
+ }
1161
+
1162
+ getTerminalMode(sessionId: string): TerminalMode {
1163
+ return this.requireTmuxSession(sessionId).mode;
1164
+ }
1165
+
1166
+ async getTerminalInfo(sessionId: string): Promise<TerminalInfo> {
1167
+ return this.requireTmuxSession(sessionId).getTerminalInfo();
1168
+ }
1169
+
1170
+ async enterScrollMode(sessionId: string): Promise<void> {
1171
+ await this.requireTmuxSession(sessionId).enterScrollMode();
1172
+ }
1173
+
1174
+ async exitScrollMode(sessionId: string): Promise<void> {
1175
+ await this.requireTmuxSession(sessionId).exitScrollMode();
1176
+ }
1177
+
1178
+ async scrollTerminal(
1179
+ sessionId: string,
1180
+ direction: "up" | "down",
1181
+ options?: { lines?: number; page?: boolean }
1182
+ ): Promise<void> {
1183
+ const tmux = this.requireTmuxSession(sessionId);
1184
+ if (options?.page) {
1185
+ await tmux.scrollPage(direction);
1186
+ } else {
1187
+ await tmux.scroll(direction, options?.lines ?? 1);
1188
+ }
1189
+ }
1190
+
1191
+ async scrollToEdge(sessionId: string, edge: "top" | "bottom"): Promise<void> {
1192
+ await this.requireTmuxSession(sessionId).scrollToEdge(edge);
1193
+ }
1194
+
1195
+ async searchTerminal(sessionId: string, query: string): Promise<void> {
1196
+ await this.requireTmuxSession(sessionId).searchBackward(query);
1197
+ }
1198
+
1199
+ async searchNext(sessionId: string): Promise<void> {
1200
+ await this.requireTmuxSession(sessionId).searchNext();
1201
+ }
1202
+
1203
+ async searchPrevious(sessionId: string): Promise<void> {
1204
+ await this.requireTmuxSession(sessionId).searchPrevious();
1205
+ }
1206
+
1207
+ /**
1208
+ * Switch model for a session (claude-code only)
1209
+ * Uses the /model slash command
1210
+ */
1211
+ async switchModel(sessionId: string, model: string): Promise<void> {
1212
+ const session = this.requireSession(sessionId);
1213
+
1214
+ const capabilities = getProviderDefinition(session.provider).capabilities;
1215
+ if (!capabilities.supportsModelSwitch) {
1216
+ throw new Error(`Model switching is not supported for provider ${session.provider}`);
1217
+ }
1218
+
1219
+ if (session.process.closed) {
1220
+ throw new Error(`Session ${sessionId} is closed`);
1221
+ }
1222
+
1223
+ try {
1224
+ // Send /model command
1225
+ const command = `/model ${model}\r`;
1226
+ session.process.write(command);
1227
+
1228
+ // Update metadata to track current model
1229
+ session.metadata = {
1230
+ ...session.metadata,
1231
+ currentModel: model,
1232
+ };
1233
+ session.updatedAt = new Date();
1234
+
1235
+ // Update database
1236
+ this.db.updateSession(sessionId, {
1237
+ metadata: session.metadata,
1238
+ });
1239
+
1240
+ // Record model switch in database
1241
+ this.db.insertModelSwitch({
1242
+ sessionId,
1243
+ fromModel: session.metadata?.previousModel as string | undefined,
1244
+ toModel: model,
1245
+ reason: "Manual model switch via API",
1246
+ });
1247
+
1248
+ // Update previous model for next switch
1249
+ session.metadata.previousModel = model;
1250
+ } catch (error) {
1251
+ const errorMsg = error instanceof Error ? error.message : String(error);
1252
+ throw new Error(`Failed to switch model for session ${sessionId}: ${errorMsg}`);
1253
+ }
1254
+ }
1255
+
1256
+ /**
1257
+ * Get current model for a session
1258
+ */
1259
+ getCurrentModel(sessionId: string): string | undefined {
1260
+ const session = this.requireSession(sessionId);
1261
+ return session.metadata?.currentModel as string | undefined;
1262
+ }
1263
+
1264
+ updateSessionStatus(sessionId: string, status: SessionStatus): void {
1265
+ const session = this.requireSession(sessionId);
1266
+ session.status = status;
1267
+ session.updatedAt = new Date();
1268
+ }
1269
+
1270
+ updateSessionMetadata(
1271
+ sessionId: string,
1272
+ updates: { transcriptPath?: string; [key: string]: any }
1273
+ ): void {
1274
+ const session = this.requireSession(sessionId);
1275
+
1276
+ if (updates.transcriptPath !== undefined) {
1277
+ session.transcriptPath = updates.transcriptPath;
1278
+ }
1279
+
1280
+ const { transcriptPath, ...metadataUpdates } = updates;
1281
+ if (Object.keys(metadataUpdates).length > 0) {
1282
+ session.metadata = { ...session.metadata, ...metadataUpdates };
1283
+ }
1284
+
1285
+ session.updatedAt = new Date();
1286
+ }
1287
+
1288
+ setSessionCustomName(sessionId: string, customName: string | null): SessionHandle {
1289
+ const session = this.requireSession(sessionId);
1290
+ const currentMetadata = (session.metadata ?? {}) as SessionMetadataShape;
1291
+ const currentUi = isObjectRecord(currentMetadata.ui) ? currentMetadata.ui : {};
1292
+ const nextUi = { ...currentUi };
1293
+
1294
+ if (customName) {
1295
+ nextUi.customName = customName;
1296
+ } else {
1297
+ delete nextUi.customName;
1298
+ }
1299
+
1300
+ const nextMetadata: SessionMetadataShape = { ...currentMetadata };
1301
+ if (Object.keys(nextUi).length > 0) {
1302
+ nextMetadata.ui = nextUi;
1303
+ } else {
1304
+ delete nextMetadata.ui;
1305
+ }
1306
+
1307
+ const persistedMetadata = Object.keys(nextMetadata).length > 0 ? nextMetadata : {};
1308
+ session.metadata = persistedMetadata;
1309
+ session.updatedAt = new Date();
1310
+ this.db.updateSession(sessionId, { metadata: persistedMetadata });
1311
+
1312
+ if (this.wsManager) {
1313
+ this.wsManager.broadcastSessionChange({
1314
+ type: "session_updated",
1315
+ session: this.toHandle(session),
1316
+ } as any);
1317
+ }
1318
+
1319
+ return this.toHandle(session);
1320
+ }
1321
+
1322
+ private async detectCodexProviderSessionId(
1323
+ cwd: string,
1324
+ opts?: { timeoutMs?: number; cwdTimestampFloor?: number }
1325
+ ): Promise<string | undefined> {
1326
+ const timeoutMs = opts?.timeoutMs ?? 6000;
1327
+ const cwdTimestampFloor = opts?.cwdTimestampFloor ?? Date.now() - 30_000;
1328
+ const deadline = Date.now() + timeoutMs;
1329
+
1330
+ while (Date.now() <= deadline) {
1331
+ const candidate = this.findCodexProviderSessionId(cwd, cwdTimestampFloor);
1332
+ if (candidate) {
1333
+ return candidate;
1334
+ }
1335
+ await Bun.sleep(250);
1336
+ }
1337
+
1338
+ return undefined;
1339
+ }
1340
+
1341
+ private findCodexProviderSessionId(cwd: string, cwdTimestampFloor: number): string | undefined {
1342
+ const codexSessionsRoot = path.join(getHomeDir(), ".codex", "sessions");
1343
+ if (!fs.existsSync(codexSessionsRoot)) {
1344
+ return undefined;
1345
+ }
1346
+
1347
+ const targetCwd = path.resolve(cwd);
1348
+ const filePaths: string[] = [];
1349
+ const stack = [codexSessionsRoot];
1350
+ while (stack.length > 0) {
1351
+ const dir = stack.pop();
1352
+ if (!dir) {
1353
+ continue;
1354
+ }
1355
+ let entries: fs.Dirent[];
1356
+ try {
1357
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1358
+ } catch {
1359
+ continue;
1360
+ }
1361
+ for (const entry of entries) {
1362
+ const entryPath = path.join(dir, entry.name);
1363
+ if (entry.isDirectory()) {
1364
+ stack.push(entryPath);
1365
+ continue;
1366
+ }
1367
+ if (entry.isFile() && /^rollout-.*\.jsonl$/i.test(entry.name)) {
1368
+ filePaths.push(entryPath);
1369
+ }
1370
+ }
1371
+ }
1372
+
1373
+ const fileMtime = (filePath: string): number => {
1374
+ try {
1375
+ return fs.statSync(filePath).mtimeMs;
1376
+ } catch {
1377
+ return 0;
1378
+ }
1379
+ };
1380
+ filePaths.sort((a, b) => fileMtime(b) - fileMtime(a));
1381
+
1382
+ for (const filePath of filePaths) {
1383
+ let stat: fs.Stats;
1384
+ try {
1385
+ stat = fs.statSync(filePath);
1386
+ } catch {
1387
+ continue;
1388
+ }
1389
+ if (stat.mtimeMs < cwdTimestampFloor) {
1390
+ continue;
1391
+ }
1392
+
1393
+ let firstLine = "";
1394
+ try {
1395
+ const content = fs.readFileSync(filePath, "utf8");
1396
+ firstLine = content.split("\n", 1)[0] ?? "";
1397
+ } catch {
1398
+ continue;
1399
+ }
1400
+ if (!firstLine) {
1401
+ continue;
1402
+ }
1403
+
1404
+ let parsed: any;
1405
+ try {
1406
+ parsed = JSON.parse(firstLine);
1407
+ } catch {
1408
+ continue;
1409
+ }
1410
+
1411
+ const payload = parsed?.payload;
1412
+ const providerSessionId = payload?.id;
1413
+ const payloadCwd = payload?.cwd;
1414
+ if (
1415
+ typeof providerSessionId === "string" &&
1416
+ providerSessionId.length > 0 &&
1417
+ typeof payloadCwd === "string"
1418
+ ) {
1419
+ try {
1420
+ if (path.resolve(payloadCwd) === targetCwd) {
1421
+ return providerSessionId;
1422
+ }
1423
+ } catch {
1424
+ // Ignore malformed path segments and continue scanning.
1425
+ }
1426
+ }
1427
+ }
1428
+
1429
+ return undefined;
1430
+ }
1431
+
1432
+ async setTranscriptPath(
1433
+ sessionId: string,
1434
+ transcriptPath: string,
1435
+ db: Database,
1436
+ eventBus: EventBus
1437
+ ): Promise<void> {
1438
+ const session = this.requireSession(sessionId);
1439
+
1440
+ session.transcriptPath = transcriptPath;
1441
+ session.updatedAt = new Date();
1442
+
1443
+ await this.startTranscriptTailer(sessionId, transcriptPath, db, eventBus);
1444
+ }
1445
+
1446
+ hasActiveTailer(sessionId: string): boolean {
1447
+ return this.tailers.has(sessionId);
1448
+ }
1449
+
1450
+ // --- Private helpers ---
1451
+
1452
+ private requireSession(sessionId: string): ManagedSession {
1453
+ const session = this.sessions.get(sessionId);
1454
+ if (!session) {
1455
+ throw new SessionNotFoundError(sessionId);
1456
+ }
1457
+ return session;
1458
+ }
1459
+
1460
+ private toHandle(session: ManagedSession): SessionHandle {
1461
+ const { process: _, ...handle } = session;
1462
+ return handle;
1463
+ }
1464
+
1465
+ private async startTranscriptTailer(
1466
+ sessionId: string,
1467
+ transcriptPath: string,
1468
+ db: Database,
1469
+ eventBus: EventBus
1470
+ ): Promise<void> {
1471
+ await this.stopTranscriptTailer(sessionId);
1472
+
1473
+ const { byteOffset } = db.getTranscriptOffset(sessionId, transcriptPath);
1474
+
1475
+ const tailer = new TranscriptTailer({
1476
+ sessionId,
1477
+ transcriptPath,
1478
+ initialOffset: byteOffset,
1479
+ onLine: (line, offset) => {
1480
+ this.handleTranscriptLine(sessionId, line, offset, db, eventBus);
1481
+ },
1482
+ onError: (err) => {
1483
+ console.error(`Transcript tailer error for session ${sessionId}:`, err);
1484
+ },
1485
+ });
1486
+
1487
+ await tailer.start();
1488
+
1489
+ this.tailers.set(sessionId, tailer);
1490
+ this.tailerContexts.set(sessionId, { db, transcriptPath });
1491
+
1492
+ this.saveOffsetIntervals.set(
1493
+ sessionId,
1494
+ setInterval(() => this.saveTranscriptOffset(sessionId), 1000)
1495
+ );
1496
+ }
1497
+
1498
+ async stopTranscriptTailer(sessionId: string): Promise<void> {
1499
+ this.saveTranscriptOffset(sessionId);
1500
+
1501
+ const tailer = this.tailers.get(sessionId);
1502
+ if (tailer) {
1503
+ await tailer.stop();
1504
+ this.tailers.delete(sessionId);
1505
+ }
1506
+
1507
+ const interval = this.saveOffsetIntervals.get(sessionId);
1508
+ if (interval) {
1509
+ clearInterval(interval);
1510
+ this.saveOffsetIntervals.delete(sessionId);
1511
+ }
1512
+
1513
+ this.tailerContexts.delete(sessionId);
1514
+ }
1515
+
1516
+ private handleTranscriptLine(
1517
+ sessionId: string,
1518
+ line: string,
1519
+ _offset: number,
1520
+ db: Database,
1521
+ eventBus: EventBus
1522
+ ): void {
1523
+ try {
1524
+ const parsed = JSON.parse(line);
1525
+ const type = parsed.type || "unknown";
1526
+
1527
+ const eventId = db.insertEvent({
1528
+ sessionId,
1529
+ source: "transcript",
1530
+ type,
1531
+ payload: parsed,
1532
+ });
1533
+
1534
+ // Extract token usage from assistant messages
1535
+ if (type === "assistant" && parsed.message?.usage) {
1536
+ const usage = parsed.message.usage;
1537
+ const promptTokens = usage.input_tokens || 0;
1538
+ const completionTokens = usage.output_tokens || 0;
1539
+ const cacheCreation = usage.cache_creation_input_tokens || 0;
1540
+ const cacheRead = usage.cache_read_input_tokens || 0;
1541
+
1542
+ db.insertTokenUsage({
1543
+ sessionId,
1544
+ eventId,
1545
+ model: parsed.message.model || "unknown",
1546
+ promptTokens,
1547
+ completionTokens,
1548
+ cacheCreationInputTokens: cacheCreation,
1549
+ cacheReadInputTokens: cacheRead,
1550
+ totalTokens: promptTokens + completionTokens + cacheCreation + cacheRead,
1551
+ });
1552
+ }
1553
+
1554
+ eventBus.emit("session:event", {
1555
+ id: eventId,
1556
+ sessionId,
1557
+ type,
1558
+ source: "transcript",
1559
+ timestamp: new Date(),
1560
+ payload: parsed,
1561
+ });
1562
+ } catch (error) {
1563
+ console.error(`Failed to parse transcript line for session ${sessionId}:`, error);
1564
+ }
1565
+ }
1566
+
1567
+ private saveTranscriptOffset(sessionId: string): void {
1568
+ const tailer = this.tailers.get(sessionId);
1569
+ const context = this.tailerContexts.get(sessionId);
1570
+ if (!(tailer && context)) {
1571
+ return;
1572
+ }
1573
+
1574
+ context.db.updateTranscriptOffset(sessionId, context.transcriptPath, {
1575
+ byteOffset: tailer.getCurrentOffset(),
1576
+ lastLineHash: null,
1577
+ });
1578
+ }
1579
+
1580
+ private cleanupImageDir(sessionId: string): void {
1581
+ const imageDir = this.getImageDir(sessionId);
1582
+ try {
1583
+ if (fs.existsSync(imageDir)) {
1584
+ fs.rmSync(imageDir, { recursive: true, force: true });
1585
+ }
1586
+ } catch (err) {
1587
+ console.warn(`Image dir cleanup failed for session ${sessionId}:`, err);
1588
+ }
1589
+ }
1590
+
1591
+ private cleanupSessionSecretFiles(sessionId: string): void {
1592
+ const runtimeDir = this.getSessionRuntimeDir(sessionId);
1593
+ const filesToRemove = [
1594
+ `${sessionId}.json`,
1595
+ `${sessionId}.hook-forward.sh`,
1596
+ `${sessionId}.statusline-forward.sh`,
1597
+ ];
1598
+
1599
+ for (const fileName of filesToRemove) {
1600
+ const filePath = path.join(runtimeDir, fileName);
1601
+ try {
1602
+ if (fs.existsSync(filePath)) {
1603
+ fs.rmSync(filePath, { force: true });
1604
+ }
1605
+ } catch (err) {
1606
+ console.warn(`Overlay cleanup failed for session ${sessionId} (${fileName}):`, err);
1607
+ }
1608
+ }
1609
+ }
1610
+
1611
+ private isExpectedClosedDbError(error: unknown): boolean {
1612
+ if (!(error instanceof Error)) {
1613
+ return false;
1614
+ }
1615
+ return error.message.toLowerCase().includes("closed database");
1616
+ }
1617
+
1618
+ private handlePtyData(sessionId: string, data: string, cursor?: TerminalCursor): void {
1619
+ const session = this.sessions.get(sessionId);
1620
+
1621
+ // Update session status on first output
1622
+ if (session && session.status === "STARTING") {
1623
+ session.status = "RUNNING";
1624
+ session.updatedAt = new Date();
1625
+ try {
1626
+ this.db.updateSession(sessionId, { status: "RUNNING" });
1627
+ } catch (error) {
1628
+ // Session callbacks may race with daemon shutdown/db close.
1629
+ if (!this.isExpectedClosedDbError(error)) {
1630
+ console.warn(`Failed to persist RUNNING status for session ${sessionId}:`, error);
1631
+ }
1632
+ }
1633
+
1634
+ // Broadcast status change to WebSocket subscribers
1635
+ if (this.wsManager) {
1636
+ this.wsManager.broadcastSessionChange({
1637
+ type: "session_updated",
1638
+ session: this.toHandle(session),
1639
+ } as any);
1640
+ }
1641
+ }
1642
+
1643
+ this.maybeCaptureCodexProviderSessionIdFromOutput(sessionId, data);
1644
+
1645
+ // Broadcast PTY/Tmux output to WebSocket subscribers
1646
+ if (this.wsManager) {
1647
+ if (cursor) {
1648
+ this.wsManager.broadcastPtyData(sessionId, data, cursor);
1649
+ } else {
1650
+ this.wsManager.broadcastPtyData(sessionId, data);
1651
+ }
1652
+ }
1653
+ }
1654
+
1655
+ private handleModeChange(sessionId: string, mode: TerminalMode): void {
1656
+ if (this.wsManager) {
1657
+ this.wsManager.broadcastSessionEvent(sessionId, {
1658
+ type: "terminal_mode_change",
1659
+ sessionId,
1660
+ mode,
1661
+ timestamp: new Date().toISOString(),
1662
+ });
1663
+ }
1664
+ }
1665
+
1666
+ private handlePtyExit(sessionId: string, exitCode: number, _signal: string | null): void {
1667
+ const session = this.sessions.get(sessionId);
1668
+ if (!session) {
1669
+ return;
1670
+ }
1671
+
1672
+ const newStatus = exitCode === 0 ? "STOPPED" : "CRASHED";
1673
+ session.status = newStatus;
1674
+ session.updatedAt = new Date();
1675
+ try {
1676
+ this.db.updateSession(sessionId, { status: newStatus });
1677
+ } catch (error) {
1678
+ if (!this.isExpectedClosedDbError(error)) {
1679
+ console.warn(`Failed to persist exit status for session ${sessionId}:`, error);
1680
+ }
1681
+ }
1682
+
1683
+ // Broadcast status change to WebSocket subscribers
1684
+ if (this.wsManager) {
1685
+ this.wsManager.broadcastSessionChange({
1686
+ type: "session_updated",
1687
+ session: this.toHandle(session),
1688
+ } as any);
1689
+ }
1690
+
1691
+ // Natural exits can happen outside explicit stop/kill paths.
1692
+ // Ensure per-session resources are cleaned up and in-memory state is released.
1693
+ void this.cleanupExitedSession(sessionId).catch((error) => {
1694
+ console.warn(`Session exit cleanup failed for ${sessionId}:`, error);
1695
+ });
1696
+ }
1697
+
1698
+ private async cleanupExitedSession(sessionId: string): Promise<void> {
1699
+ // Session may already be cleaned up by stop/kill concurrent path.
1700
+ if (!this.sessions.has(sessionId)) {
1701
+ return;
1702
+ }
1703
+
1704
+ await this.stopTranscriptTailer(sessionId);
1705
+ this.cleanupSessionSecretFiles(sessionId);
1706
+ this.cleanupImageDir(sessionId);
1707
+ this.wsManager?.clearPtySequence(sessionId);
1708
+ this.inputQueueBySession.delete(sessionId);
1709
+ this.codexBannerBufferBySession.delete(sessionId);
1710
+ this.sessions.delete(sessionId);
1711
+ }
1712
+
1713
+ private maybeCaptureCodexProviderSessionIdFromOutput(sessionId: string, data: string): void {
1714
+ const session = this.sessions.get(sessionId);
1715
+ if (!session || session.provider !== "codex") {
1716
+ return;
1717
+ }
1718
+
1719
+ const currentMetadata = (session.metadata ?? {}) as SessionMetadataShape;
1720
+ if (
1721
+ typeof currentMetadata.providerSession?.id === "string" &&
1722
+ currentMetadata.providerSession.id.trim().length > 0
1723
+ ) {
1724
+ this.codexBannerBufferBySession.delete(sessionId);
1725
+ return;
1726
+ }
1727
+
1728
+ const stripped = stripVTControlCharacters(data);
1729
+ const prior = this.codexBannerBufferBySession.get(sessionId) ?? "";
1730
+ const combined = `${prior}${stripped}`;
1731
+ const match = combined.match(CODEX_SESSION_BANNER_REGEX);
1732
+ if (!match) {
1733
+ this.codexBannerBufferBySession.set(
1734
+ sessionId,
1735
+ combined.slice(-CODEX_SESSION_BANNER_BUFFER_BYTES)
1736
+ );
1737
+ return;
1738
+ }
1739
+
1740
+ const providerSessionId = match[1];
1741
+ const nextMetadata: SessionMetadataShape = {
1742
+ ...currentMetadata,
1743
+ providerSession: {
1744
+ ...(currentMetadata.providerSession ?? {}),
1745
+ id: providerSessionId,
1746
+ mode: currentMetadata.providerSession?.mode ?? "resume",
1747
+ source: "codex-session-configured-banner",
1748
+ },
1749
+ };
1750
+
1751
+ session.metadata = nextMetadata;
1752
+ session.updatedAt = new Date();
1753
+ this.codexBannerBufferBySession.delete(sessionId);
1754
+
1755
+ try {
1756
+ this.db.updateSession(sessionId, { metadata: nextMetadata });
1757
+ } catch (error) {
1758
+ if (!this.isExpectedClosedDbError(error)) {
1759
+ console.warn(`Failed to persist codex provider session id for ${sessionId}:`, error);
1760
+ }
1761
+ }
1762
+
1763
+ if (this.wsManager) {
1764
+ this.wsManager.broadcastSessionChange({
1765
+ type: "session_updated",
1766
+ session: this.toHandle(session),
1767
+ } as any);
1768
+ }
1769
+ }
1770
+ }