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.
- package/.env.example +28 -0
- package/CHANGELOG.md +10 -0
- package/LEGAL_NOTICE.md +39 -0
- package/LICENSE +21 -0
- package/README.md +524 -0
- package/package.json +90 -0
- package/packages/cli/package.json +13 -0
- package/packages/cli/src/commands/analytics.ts +157 -0
- package/packages/cli/src/commands/attach.ts +299 -0
- package/packages/cli/src/commands/audit.ts +50 -0
- package/packages/cli/src/commands/auth.ts +261 -0
- package/packages/cli/src/commands/daemon.ts +162 -0
- package/packages/cli/src/commands/doctor.ts +303 -0
- package/packages/cli/src/commands/env-set.ts +162 -0
- package/packages/cli/src/commands/hook-forward.ts +268 -0
- package/packages/cli/src/commands/keys.ts +77 -0
- package/packages/cli/src/commands/kill.ts +19 -0
- package/packages/cli/src/commands/logs.ts +419 -0
- package/packages/cli/src/commands/model.ts +172 -0
- package/packages/cli/src/commands/policy-set.ts +185 -0
- package/packages/cli/src/commands/policy.ts +227 -0
- package/packages/cli/src/commands/providers.ts +114 -0
- package/packages/cli/src/commands/resize.ts +34 -0
- package/packages/cli/src/commands/send.ts +184 -0
- package/packages/cli/src/commands/sessions.ts +202 -0
- package/packages/cli/src/commands/slash.ts +92 -0
- package/packages/cli/src/commands/start.ts +243 -0
- package/packages/cli/src/commands/stop.ts +19 -0
- package/packages/cli/src/commands/tail.ts +137 -0
- package/packages/cli/src/commands/workflow.ts +786 -0
- package/packages/cli/src/commands/workspace.ts +127 -0
- package/packages/cli/src/lib/api.ts +78 -0
- package/packages/cli/src/lib/args.ts +72 -0
- package/packages/cli/src/lib/format.ts +93 -0
- package/packages/cli/src/main.ts +563 -0
- package/packages/core/package.json +7 -0
- package/packages/core/src/config.ts +30 -0
- package/packages/core/src/errors.ts +38 -0
- package/packages/core/src/eventBus.ts +56 -0
- package/packages/core/src/eventBusAdapter.ts +143 -0
- package/packages/core/src/index.ts +10 -0
- package/packages/core/src/sqliteEventBus.ts +336 -0
- package/packages/core/src/types.ts +63 -0
- package/packages/daemon/package.json +11 -0
- package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
- package/packages/daemon/src/api/authRoutes.ts +344 -0
- package/packages/daemon/src/api/bodyLimit.ts +133 -0
- package/packages/daemon/src/api/envSetRoutes.ts +170 -0
- package/packages/daemon/src/api/gitRoutes.ts +409 -0
- package/packages/daemon/src/api/hooks.ts +588 -0
- package/packages/daemon/src/api/inputPolicy.ts +249 -0
- package/packages/daemon/src/api/notificationRoutes.ts +532 -0
- package/packages/daemon/src/api/policyRoutes.ts +234 -0
- package/packages/daemon/src/api/policySetRoutes.ts +445 -0
- package/packages/daemon/src/api/routeUtils.ts +28 -0
- package/packages/daemon/src/api/routes.ts +1004 -0
- package/packages/daemon/src/api/server.ts +1388 -0
- package/packages/daemon/src/api/settingsRoutes.ts +367 -0
- package/packages/daemon/src/api/sqliteErrors.ts +47 -0
- package/packages/daemon/src/api/stt.ts +143 -0
- package/packages/daemon/src/api/terminalRoutes.ts +200 -0
- package/packages/daemon/src/api/validation.ts +287 -0
- package/packages/daemon/src/api/validationRoutes.ts +174 -0
- package/packages/daemon/src/api/workflowRoutes.ts +567 -0
- package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
- package/packages/daemon/src/api/ws.ts +1588 -0
- package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
- package/packages/daemon/src/auth/authMiddleware.ts +305 -0
- package/packages/daemon/src/auth/authService.ts +496 -0
- package/packages/daemon/src/auth/rateLimiter.ts +137 -0
- package/packages/daemon/src/config/pricing.ts +79 -0
- package/packages/daemon/src/crypto/encryption.ts +196 -0
- package/packages/daemon/src/db/db.ts +2745 -0
- package/packages/daemon/src/db/index.ts +16 -0
- package/packages/daemon/src/db/migrations.ts +182 -0
- package/packages/daemon/src/db/policyDb.ts +349 -0
- package/packages/daemon/src/db/schema.sql +408 -0
- package/packages/daemon/src/db/workflowDb.ts +464 -0
- package/packages/daemon/src/git/gitUtils.ts +544 -0
- package/packages/daemon/src/index.ts +6 -0
- package/packages/daemon/src/main.ts +525 -0
- package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
- package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
- package/packages/daemon/src/providers/registry.ts +111 -0
- package/packages/daemon/src/providers/types.ts +82 -0
- package/packages/daemon/src/sessions/auditLogger.ts +103 -0
- package/packages/daemon/src/sessions/policyEngine.ts +165 -0
- package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
- package/packages/daemon/src/sessions/policyTypes.ts +94 -0
- package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
- package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
- package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
- package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
- package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
- package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
- package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
- package/packages/daemon/src/workflows/contextManager.ts +83 -0
- package/packages/daemon/src/workflows/index.ts +31 -0
- package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
- package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
- package/packages/daemon/src/workflows/workflowParser.ts +217 -0
- package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
- package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
- package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
- package/packages/providers/claude-code/package.json +11 -0
- package/packages/providers/claude-code/src/index.ts +7 -0
- package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
- package/packages/providers/claude-code/src/provider.ts +311 -0
- package/packages/web/dist/android-chrome-192x192.png +0 -0
- package/packages/web/dist/android-chrome-512x512.png +0 -0
- package/packages/web/dist/apple-touch-icon.png +0 -0
- package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
- package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
- package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
- package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
- package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
- package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
- package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
- package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
- package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
- package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
- package/packages/web/dist/assets/index-hgphORiw.js +204 -0
- package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
- package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
- package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
- package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
- package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
- package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
- package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
- package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
- package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
- package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
- package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
- package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
- package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/icon.svg +1 -0
- package/packages/web/dist/index.html +29 -0
- package/packages/web/dist/manifest.json +29 -0
- package/packages/web/dist/og-image.png +0 -0
- package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
- package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
- package/packages/web/dist/originals/apple-touch-icon.png +0 -0
- package/packages/web/dist/originals/favicon.ico +0 -0
- package/packages/web/dist/piper.svg +1 -0
- package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
- package/packages/web/dist/sw.js +257 -0
- 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
|
+
}
|