glad-web 1.0.44 → 1.0.46

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.
@@ -0,0 +1,168 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const { v4: uuidv4 } = require('uuid');
5
+
6
+ const MAX_BYTES = 50 * 1024 * 1024;
7
+ const MAX_PER_SESSION = 8;
8
+ const MAX_CHUNKS = 128;
9
+ const CLEANUP_DELAY_MS = 30 * 60 * 1000;
10
+
11
+ function inputError(message, statusCode = 400) {
12
+ const error = new Error(message);
13
+ error.statusCode = statusCode;
14
+ return error;
15
+ }
16
+
17
+ function safeUploadId(value) {
18
+ return typeof value === 'string' && /^[a-zA-Z0-9-]{8,100}$/.test(value);
19
+ }
20
+
21
+ function safeFileName(value) {
22
+ let decoded = String(value || 'attachment.bin');
23
+ try { decoded = decodeURIComponent(decoded); } catch (_) {}
24
+ const base = decoded.replace(/\\/g, '/').split('/').pop() || 'attachment.bin';
25
+ const cleaned = base.replace(/[\u0000-\u001f\u007f<>:"|?*]/g, '_').trim().slice(0, 160);
26
+ return cleaned || 'attachment.bin';
27
+ }
28
+
29
+ class FileAttachmentStore {
30
+ constructor({ logger, root, uploadRoot } = {}) {
31
+ this.logger = logger || console;
32
+ this.root = root || path.join(os.tmpdir(), 'glad', 'session-files');
33
+ this.uploadRoot = uploadRoot || path.join(os.tmpdir(), 'glad', 'session-file-uploads');
34
+ }
35
+
36
+ assertSession(session) {
37
+ if (!session) throw inputError('Session not found', 404);
38
+ if (!session.fileAttachments || !session.fileUploads) throw inputError('File attachments are unavailable for this session');
39
+ }
40
+
41
+ async appendChunk(session, input = {}, bytes) {
42
+ this.assertSession(session);
43
+ if (!Buffer.isBuffer(bytes) || bytes.length === 0) throw inputError('File chunk is required');
44
+ const uploadId = String(input.uploadId || '');
45
+ const chunkIndex = Number(input.chunkIndex);
46
+ const chunkTotal = Number(input.chunkTotal);
47
+ const name = safeFileName(input.name);
48
+ if (!safeUploadId(uploadId)) throw inputError('Invalid file upload id');
49
+ if (!Number.isInteger(chunkIndex) || !Number.isInteger(chunkTotal) || chunkIndex < 0 || chunkTotal < 1 || chunkTotal > MAX_CHUNKS || chunkIndex >= chunkTotal) {
50
+ throw inputError('Invalid file chunk metadata');
51
+ }
52
+
53
+ let upload = session.fileUploads.get(uploadId);
54
+ if (!upload) {
55
+ if (chunkIndex !== 0) throw inputError('File upload must start with the first chunk');
56
+ const directory = path.join(this.uploadRoot, session.id);
57
+ await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
58
+ upload = { id: uploadId, name, path: path.join(directory, `${uploadId}.part`), chunkTotal, nextChunkIndex: 0, bytes: 0 };
59
+ session.fileUploads.set(uploadId, upload);
60
+ }
61
+ if (upload.name !== name || upload.chunkTotal !== chunkTotal || upload.nextChunkIndex !== chunkIndex) {
62
+ throw inputError('File chunks arrived out of order');
63
+ }
64
+ if (upload.bytes + bytes.length > MAX_BYTES) {
65
+ await this.discardUpload(session, uploadId);
66
+ throw inputError('File must be 50 MB or smaller');
67
+ }
68
+
69
+ if (chunkIndex === 0) await fs.promises.writeFile(upload.path, bytes, { mode: 0o600, flag: 'wx' });
70
+ else await fs.promises.appendFile(upload.path, bytes, { mode: 0o600 });
71
+ upload.bytes += bytes.length;
72
+ upload.nextChunkIndex += 1;
73
+ if (upload.nextChunkIndex < upload.chunkTotal) {
74
+ return { complete: false, receivedChunks: upload.nextChunkIndex, size: upload.bytes };
75
+ }
76
+
77
+ const pendingAttachmentCount = Array.from(session.fileAttachments.values()).filter(item => !item.sent).length;
78
+ if (pendingAttachmentCount >= MAX_PER_SESSION) {
79
+ await this.discardUpload(session, uploadId);
80
+ throw inputError(`You can attach at most ${MAX_PER_SESSION} files at a time`);
81
+ }
82
+ const directory = path.join(this.root, session.id);
83
+ await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
84
+ const attachment = {
85
+ id: uuidv4(),
86
+ name: upload.name,
87
+ path: path.join(directory, `${uuidv4()}-${upload.name}`),
88
+ size: upload.bytes,
89
+ createdAt: Date.now(),
90
+ cleanupTimer: null
91
+ };
92
+ session.fileUploads.delete(uploadId);
93
+ try {
94
+ await fs.promises.rename(upload.path, attachment.path);
95
+ await fs.promises.chmod(attachment.path, 0o600);
96
+ session.fileAttachments.set(attachment.id, attachment);
97
+ return { complete: true, attachment: { id: attachment.id, name: attachment.name, size: attachment.size, kind: 'file' } };
98
+ } catch (error) {
99
+ await fs.promises.rm(upload.path, { force: true });
100
+ throw error;
101
+ }
102
+ }
103
+
104
+ async discardUpload(session, uploadId) {
105
+ if (!session?.fileUploads || !safeUploadId(uploadId)) return false;
106
+ const upload = session.fileUploads.get(uploadId);
107
+ if (!upload) return false;
108
+ session.fileUploads.delete(uploadId);
109
+ await fs.promises.rm(upload.path, { force: true });
110
+ return true;
111
+ }
112
+
113
+ async discardAttachment(session, attachmentId) {
114
+ if (!session?.fileAttachments) return false;
115
+ const attachment = session.fileAttachments.get(attachmentId);
116
+ if (!attachment) return false;
117
+ clearTimeout(attachment.cleanupTimer);
118
+ session.fileAttachments.delete(attachmentId);
119
+ await fs.promises.rm(attachment.path, { force: true });
120
+ return true;
121
+ }
122
+
123
+ resolve(session, attachmentIds = []) {
124
+ const ids = Array.isArray(attachmentIds) ? attachmentIds : [];
125
+ if (!session) throw inputError('Session not found', 404);
126
+ if (ids.length === 0) return [];
127
+ this.assertSession(session);
128
+ if (ids.length > MAX_PER_SESSION) throw inputError(`You can attach at most ${MAX_PER_SESSION} files at a time`);
129
+ const uniqueIds = [...new Set(ids.map(String))];
130
+ if (uniqueIds.length !== ids.length) throw inputError('Duplicate file attachment');
131
+ return uniqueIds.map(attachmentId => {
132
+ const attachment = session.fileAttachments.get(attachmentId);
133
+ if (!attachment) throw inputError('File attachment is no longer available');
134
+ return attachment;
135
+ });
136
+ }
137
+
138
+ scheduleCleanup(session, attachmentIds) {
139
+ if (!session?.fileAttachments) return;
140
+ for (const attachmentId of attachmentIds) {
141
+ const attachment = session.fileAttachments.get(attachmentId);
142
+ if (!attachment) continue;
143
+ attachment.sent = true;
144
+ clearTimeout(attachment.cleanupTimer);
145
+ attachment.cleanupTimer = setTimeout(() => {
146
+ this.discardAttachment(session, attachmentId).catch(error => {
147
+ this.logger.debugInfo?.(`[file-attachment] cleanup failed: ${error.message}`);
148
+ });
149
+ }, CLEANUP_DELAY_MS);
150
+ attachment.cleanupTimer.unref?.();
151
+ }
152
+ }
153
+
154
+ clear(session) {
155
+ if (!session) return;
156
+ for (const attachment of session.fileAttachments?.values?.() || []) clearTimeout(attachment.cleanupTimer);
157
+ session.fileAttachments?.clear?.();
158
+ session.fileUploads?.clear?.();
159
+ fs.promises.rm(path.join(this.root, session.id), { recursive: true, force: true }).catch(error => {
160
+ this.logger.debugInfo?.(`[file-attachment] cleanup failed: ${error.message}`);
161
+ });
162
+ fs.promises.rm(path.join(this.uploadRoot, session.id), { recursive: true, force: true }).catch(error => {
163
+ this.logger.debugInfo?.(`[file-upload] cleanup failed: ${error.message}`);
164
+ });
165
+ }
166
+ }
167
+
168
+ module.exports = FileAttachmentStore;
@@ -11,6 +11,7 @@ const ClaudeStructuredSession = require('../claude/structured-session');
11
11
  const CodexStructuredSession = require('../codex/structured-session');
12
12
  const ClaudeTranscriptRepository = require('../claude/transcript-repository');
13
13
  const CodexImageStore = require('../codex/image-store');
14
+ const FileAttachmentStore = require('./file-attachment-store');
14
15
 
15
16
  function previewText(text, maxChars = 320) {
16
17
  if (!text) return '';
@@ -29,8 +30,22 @@ function imageMediaType(name) {
29
30
  : extension === '.gif' ? 'image/gif' : 'image/webp';
30
31
  }
31
32
 
33
+ function promptWithFileReferences(text, attachments) {
34
+ const prompt = String(text || '').trim();
35
+ if (!attachments.length) return prompt;
36
+ const references = attachments.map(item => `- ${item.name}: ${item.path}`).join('\n');
37
+ const note = `The user attached the following local files. Read them if relevant to the request:\n${references}`;
38
+ return prompt ? `${prompt}\n\n${note}` : note;
39
+ }
40
+
41
+ const STRUCTURED_SESSION_KINDS = new Set(['claude-structured', 'codex-structured']);
42
+
43
+ function isStructuredSession(session) {
44
+ return Boolean(session && STRUCTURED_SESSION_KINDS.has(session.kind));
45
+ }
46
+
32
47
  class SessionManager extends EventEmitter {
33
- constructor({ baseDir, renderHistoryTools, debugHistoryEnabled = false, logger, hasConnectedSessionClient, claudeTranscriptRepository, codexImageStore, claudeForkSession } = {}) {
48
+ constructor({ baseDir, renderHistoryTools, debugHistoryEnabled = false, logger, hasConnectedSessionClient, claudeTranscriptRepository, codexImageStore, fileAttachmentStore, claudeForkSession } = {}) {
34
49
  super();
35
50
  this.baseDir = baseDir || process.cwd();
36
51
  this.renderHistoryTools = renderHistoryTools || new Set();
@@ -39,6 +54,7 @@ class SessionManager extends EventEmitter {
39
54
  this.hasConnectedSessionClient = hasConnectedSessionClient || (() => false);
40
55
  this.sessions = new Map();
41
56
  this.codexImages = codexImageStore || new CodexImageStore({ logger: this.logger });
57
+ this.fileAttachmentStore = fileAttachmentStore || new FileAttachmentStore({ logger: this.logger });
42
58
  Object.defineProperties(this, {
43
59
  codexImageRoot: {
44
60
  get: () => this.codexImages.root,
@@ -67,7 +83,7 @@ class SessionManager extends EventEmitter {
67
83
  startTime: session.startTime,
68
84
  toolKey: session.tool.key,
69
85
  workingDirectory: this.getSessionWorkingDirectory(session),
70
- mode: ['claude-structured', 'codex-structured'].includes(session.kind) ? (session.presentation || 'structured') : 'terminal',
86
+ mode: isStructuredSession(session) ? 'structured' : 'terminal',
71
87
  hasUnreadCompletion: Boolean(session.hasUnreadCompletion),
72
88
  serverChanNotificationEnabled: Boolean(session.serverChanNotificationEnabled),
73
89
  timedInputCount: session.timedInputs
@@ -84,7 +100,7 @@ class SessionManager extends EventEmitter {
84
100
  return this.sessions.has(id);
85
101
  }
86
102
 
87
- create({ toolKey, workingDirectory, name, claudeOptions }) {
103
+ create({ id: requestedId, toolKey, workingDirectory, name, claudeOptions, codexOptions, disposeResources }) {
88
104
  this.logger.info(`Creating session: toolKey=${toolKey || ''}, workingDirectory=${workingDirectory || '(default)'}`);
89
105
  const tool = getToolByKey(toolKey);
90
106
  if (!tool) {
@@ -98,7 +114,14 @@ class SessionManager extends EventEmitter {
98
114
  return this.createClaudeStructuredSession({ tool, workingDirectory, name, claudeOptions });
99
115
  }
100
116
  if (tool.key === 'codex') {
101
- return this.createCodexStructuredSession({ tool, workingDirectory, name });
117
+ return this.createCodexStructuredSession({
118
+ id: requestedId,
119
+ tool,
120
+ workingDirectory,
121
+ name,
122
+ codexOptions,
123
+ disposeResources
124
+ });
102
125
  }
103
126
 
104
127
  const id = uuidv4();
@@ -142,6 +165,8 @@ class SessionManager extends EventEmitter {
142
165
  hasConnectedWebClient: false,
143
166
  hasUnreadCompletion: false,
144
167
  timedInputs: new Map(),
168
+ fileAttachments: new Map(),
169
+ fileUploads: new Map(),
145
170
  write: data => this.write(id, data),
146
171
  isRunning: () => this.has(id) && ptyManager.isRunning(),
147
172
  kill: () => this.kill(id)
@@ -187,6 +212,8 @@ class SessionManager extends EventEmitter {
187
212
  });
188
213
  session.imageAttachments = new Map();
189
214
  session.imageUploads = new Map();
215
+ session.fileAttachments = new Map();
216
+ session.fileUploads = new Map();
190
217
 
191
218
  this.sessions.set(id, session);
192
219
  session.on('event', event => this.emit('claude-event', { sessionId: id, event, session }));
@@ -196,7 +223,7 @@ class SessionManager extends EventEmitter {
196
223
  return session;
197
224
  }
198
225
 
199
- createCodexStructuredSession({ tool, workingDirectory, name, codexOptions = {} }) {
226
+ createCodexStructuredSession({ id: requestedId, tool, workingDirectory, name, codexOptions = {}, disposeResources = null }) {
200
227
  const sessionDir = workingDirectory && String(workingDirectory).trim()
201
228
  ? path.resolve(this.baseDir, String(workingDirectory).trim())
202
229
  : this.baseDir;
@@ -205,13 +232,15 @@ class SessionManager extends EventEmitter {
205
232
  err.statusCode = 400;
206
233
  throw err;
207
234
  }
208
- const id = uuidv4();
235
+ const id = requestedId || uuidv4();
209
236
  const session = new CodexStructuredSession({ id, tool, workingDir: sessionDir, name: name || tool.displayName, logger: this.logger, options: codexOptions });
210
237
  session.imageAttachments = new Map();
211
238
  session.imageUploads = new Map();
239
+ session.fileAttachments = new Map();
240
+ session.fileUploads = new Map();
241
+ session.disposeResources = typeof disposeResources === 'function' ? disposeResources : null;
212
242
  this.sessions.set(id, session);
213
243
  session.on('event', event => this.emit('codex-event', { sessionId: id, event, session }));
214
- session.on('output', data => this.emit('output', { sessionId: id, data, session }));
215
244
  session.on('exit', () => this.handleExit(session));
216
245
  session.ensureProcess().catch(error => {
217
246
  session.append({ kind: 'event', level: 'error', text: `Unable to start Codex app-server: ${error.message}` });
@@ -224,16 +253,30 @@ class SessionManager extends EventEmitter {
224
253
  const session = this.get(id);
225
254
  if (!session) return false;
226
255
  this.markSessionInput(session, data);
227
- if (['claude-structured', 'codex-structured'].includes(session.kind)) return session.write(data);
256
+ if (isStructuredSession(session)) return session.write(data);
228
257
  return session.ptyManager.write(data);
229
258
  }
230
259
 
231
- async sendClaudeInput(id, text, attachmentIds = []) {
260
+ sendTerminalFileInput(id, text, fileAttachmentIds = []) {
261
+ const session = this.get(id);
262
+ if (!session || isStructuredSession(session)) return false;
263
+ const files = this.getFileAttachments(id, fileAttachmentIds);
264
+ const prompt = promptWithFileReferences(text, files);
265
+ if (!prompt) return false;
266
+ this.write(id, prompt.replace(/\n/g, '\r'));
267
+ const enterTimer = setTimeout(() => this.write(id, '\r'), 1000);
268
+ enterTimer.unref?.();
269
+ if (files.length) this.scheduleFileCleanup(id, files.map(item => item.id));
270
+ return true;
271
+ }
272
+
273
+ async sendClaudeInput(id, text, attachmentIds = [], fileAttachmentIds = []) {
232
274
  const session = this.get(id);
233
275
  if (!session || session.kind !== 'claude-structured') return false;
234
276
  const attachments = this.getImageAttachments(id, attachmentIds);
277
+ const files = this.getFileAttachments(id, fileAttachmentIds);
235
278
  const prompt = String(text || '');
236
- if (!prompt.trim() && attachments.length === 0) return false;
279
+ if (!prompt.trim() && attachments.length === 0 && files.length === 0) return false;
237
280
  const prepared = await Promise.all(attachments.map(async attachment => ({
238
281
  id: attachment.id,
239
282
  name: attachment.name,
@@ -241,12 +284,37 @@ class SessionManager extends EventEmitter {
241
284
  mediaType: imageMediaType(attachment.name),
242
285
  data: (await fs.promises.readFile(attachment.path)).toString('base64')
243
286
  })));
244
- this.markSessionInput(session, prompt || '[image attachment]');
245
- const sent = session.sendUserMessage(prompt, prepared);
287
+ const agentPrompt = promptWithFileReferences(prompt, files);
288
+ this.markSessionInput(session, prompt || (files.length ? '[file attachment]' : '[image attachment]'));
289
+ const sent = session.sendUserMessage(prompt, prepared, {
290
+ agentText: agentPrompt,
291
+ displayAttachments: files.map(item => ({ id: item.id, name: item.name, size: item.size, kind: 'file' }))
292
+ });
246
293
  if (sent && attachments.length) this.scheduleImageCleanup(id, attachments.map(item => item.id));
294
+ if (sent && files.length) this.scheduleFileCleanup(id, files.map(item => item.id));
247
295
  return sent;
248
296
  }
249
297
 
298
+ appendFileChunk(id, input = {}, bytes) {
299
+ return this.fileAttachmentStore.appendChunk(this.get(id), input, bytes);
300
+ }
301
+
302
+ discardFileUpload(id, uploadId) {
303
+ return this.fileAttachmentStore.discardUpload(this.get(id), uploadId);
304
+ }
305
+
306
+ discardFileAttachment(id, attachmentId) {
307
+ return this.fileAttachmentStore.discardAttachment(this.get(id), attachmentId);
308
+ }
309
+
310
+ getFileAttachments(id, attachmentIds = []) {
311
+ return this.fileAttachmentStore.resolve(this.get(id), attachmentIds);
312
+ }
313
+
314
+ scheduleFileCleanup(id, attachmentIds) {
315
+ this.fileAttachmentStore.scheduleCleanup(this.get(id), attachmentIds);
316
+ }
317
+
250
318
  storeImageAttachment(id, bytes) {
251
319
  return this.codexImages.store(this.get(id), bytes);
252
320
  }
@@ -295,15 +363,21 @@ class SessionManager extends EventEmitter {
295
363
  this.scheduleImageCleanup(id, attachmentIds);
296
364
  }
297
365
 
298
- async sendCodexInput(id, text, attachmentIds = [], skills = []) {
366
+ async sendCodexInput(id, text, attachmentIds = [], skills = [], fileAttachmentIds = []) {
299
367
  const session = this.get(id);
300
368
  if (!session || session.kind !== 'codex-structured') return false;
301
369
  const attachments = this.getCodexImageAttachments(id, attachmentIds);
370
+ const files = this.getFileAttachments(id, fileAttachmentIds);
302
371
  const prompt = String(text || '');
303
- if (!prompt.trim() && attachments.length === 0) return false;
304
- this.markSessionInput(session, prompt || '[image attachment]');
305
- const sent = await session.sendUserMessage(prompt, attachments, skills);
372
+ if (!prompt.trim() && attachments.length === 0 && files.length === 0) return false;
373
+ const agentPrompt = promptWithFileReferences(prompt, files);
374
+ this.markSessionInput(session, prompt || (files.length ? '[file attachment]' : '[image attachment]'));
375
+ const sent = await session.sendUserMessage(prompt, attachments, skills, {
376
+ agentText: agentPrompt,
377
+ displayAttachments: files.map(item => ({ id: item.id, name: item.name, size: item.size, kind: 'file' }))
378
+ });
306
379
  if (sent && attachments.length) this.scheduleCodexImageCleanup(id, attachments.map(item => item.id));
380
+ if (sent && files.length) this.scheduleFileCleanup(id, files.map(item => item.id));
307
381
  return sent;
308
382
  }
309
383
 
@@ -409,8 +483,8 @@ class SessionManager extends EventEmitter {
409
483
  async forkCodex(id, threadId) {
410
484
  const session = this.get(id);
411
485
  if (!session || session.kind !== 'codex-structured') return null;
412
- if (session.presentation !== 'structured' || session.status !== 'idle' || session.resuming) {
413
- const error = new Error('Codex must be idle in chat mode before forking');
486
+ if (session.status !== 'idle' || session.resuming) {
487
+ const error = new Error('Codex must be idle before forking');
414
488
  error.statusCode = 409;
415
489
  throw error;
416
490
  }
@@ -444,20 +518,11 @@ class SessionManager extends EventEmitter {
444
518
  return session.listSkills(forceReload);
445
519
  }
446
520
 
447
- switchCodexPresentation(id, presentation) {
448
- const session = this.get(id);
449
- if (!session || session.kind !== 'codex-structured') return Promise.resolve(false);
450
- return presentation === 'terminal' ? session.switchToTerminal() : session.switchToStructured();
451
- }
452
-
453
521
  resize(id, cols, rows) {
454
522
  const session = this.get(id);
455
523
  if (!session) return false;
456
524
  if (session.kind === 'claude-structured') return true;
457
- if (session.kind === 'codex-structured') {
458
- if (session.presentation === 'terminal') session.ptyManager.resize(cols, rows);
459
- return true;
460
- }
525
+ if (session.kind === 'codex-structured') return true;
461
526
  if (session.renderedHistory) {
462
527
  session.renderedHistory.resize(cols, rows);
463
528
  }
@@ -468,7 +533,7 @@ class SessionManager extends EventEmitter {
468
533
  redraw(id, cols, rows) {
469
534
  const session = this.get(id);
470
535
  if (!session) return false;
471
- if (['claude-structured', 'codex-structured'].includes(session.kind)) return true;
536
+ if (isStructuredSession(session)) return true;
472
537
  if (session.renderedHistory) {
473
538
  session.renderedHistory.resize(cols, rows);
474
539
  }
@@ -486,7 +551,7 @@ class SessionManager extends EventEmitter {
486
551
  markCompletionRead(id) {
487
552
  const session = this.get(id);
488
553
  if (!session) return null;
489
- if (['claude-structured', 'codex-structured'].includes(session.kind)) {
554
+ if (isStructuredSession(session)) {
490
555
  session.markCompletionRead();
491
556
  this.logSessionDiagnostics('completion-read', session, {}, { compact: true });
492
557
  return session;
@@ -610,33 +675,57 @@ class SessionManager extends EventEmitter {
610
675
  return true;
611
676
  }
612
677
 
613
- async kill(id) {
614
- const session = this.get(id);
615
- if (!session) return false;
678
+ quiesceSession(session) {
616
679
  clearTimeout(session.completionTimer);
617
680
  this.clearTimedInputs(session);
681
+ }
682
+
683
+ disposeSessionResources(session) {
684
+ this.quiesceSession(session);
618
685
  this.clearCodexImageUploads(session);
619
686
  this.clearCodexImageAttachments(session);
620
- this.logSessionDiagnostics('session-deleted', session, {}, { compact: true });
687
+ this.fileAttachmentStore.clear(session);
688
+ if (typeof session.disposeResources === 'function') {
689
+ try { session.disposeResources(); } catch (error) {
690
+ this.logger.error?.(`Failed to clean session resources ${session.id}: ${error.message}`);
691
+ }
692
+ session.disposeResources = null;
693
+ }
621
694
  this.disposeSessionHistory(session);
622
- if (['claude-structured', 'codex-structured'].includes(session.kind)) {
623
- await session.ptyManager.kill();
624
- return true;
695
+ }
696
+
697
+ async stopSessionRuntime(session) {
698
+ if (isStructuredSession(session)) {
699
+ await session.kill();
700
+ return;
625
701
  }
626
702
  session.ptyManager.kill();
627
- this.sessions.delete(id);
628
- this.emit('exit', { sessionId: id, session });
703
+ }
704
+
705
+ removeSession(session) {
706
+ if (this.sessions.get(session.id) !== session) return false;
707
+ this.disposeSessionResources(session);
708
+ this.sessions.delete(session.id);
709
+ this.emit('exit', { sessionId: session.id, session });
710
+ return true;
711
+ }
712
+
713
+ async kill(id) {
714
+ const session = this.get(id);
715
+ if (!session) return false;
716
+ this.logSessionDiagnostics('session-deleted', session, {}, { compact: true });
717
+ this.quiesceSession(session);
718
+ await this.stopSessionRuntime(session);
719
+ this.removeSession(session);
629
720
  return true;
630
721
  }
631
722
 
632
723
  killAll() {
633
- for (const session of this.sessions.values()) {
634
- clearTimeout(session.completionTimer);
635
- this.clearTimedInputs(session);
636
- this.clearCodexImageUploads(session);
637
- this.clearCodexImageAttachments(session);
638
- this.disposeSessionHistory(session);
639
- session.ptyManager.kill();
724
+ for (const session of Array.from(this.sessions.values())) {
725
+ this.disposeSessionResources(session);
726
+ void this.stopSessionRuntime(session).catch(error => {
727
+ this.logger.error?.(`Failed to stop session ${session.id}: ${error.message}`);
728
+ });
640
729
  }
641
730
  this.sessions.clear();
642
731
  }
@@ -644,7 +733,7 @@ class SessionManager extends EventEmitter {
644
733
  getHistory(id) {
645
734
  const session = this.get(id);
646
735
  if (!session) return null;
647
- if (['claude-structured', 'codex-structured'].includes(session.kind)) return session.getHistory();
736
+ if (isStructuredSession(session)) return session.getHistory();
648
737
  const historySource = session.renderedHistory || session.textHistory;
649
738
  return {
650
739
  success: true,
@@ -659,7 +748,7 @@ class SessionManager extends EventEmitter {
659
748
  getCatchupOutput(id) {
660
749
  const session = this.get(id);
661
750
  if (!session) return null;
662
- if (['claude-structured', 'codex-structured'].includes(session.kind)) return session.getCatchupOutput();
751
+ if (isStructuredSession(session)) return session.getCatchupOutput();
663
752
 
664
753
  const bufferHistory = session.buffer.getAfter(0);
665
754
  if (bufferHistory.length > 0) {
@@ -798,21 +887,15 @@ class SessionManager extends EventEmitter {
798
887
  }
799
888
 
800
889
  handleExit(session) {
801
- if (!this.sessions.has(session.id)) return;
890
+ if (this.sessions.get(session.id) !== session) return;
802
891
  this.logger.info(`Session ${session.id} (${session.name}) exited.`);
803
- clearTimeout(session.completionTimer);
804
- this.clearTimedInputs(session);
805
- this.clearCodexImageUploads(session);
806
- this.clearCodexImageAttachments(session);
807
- this.disposeSessionHistory(session);
808
- this.sessions.delete(session.id);
809
- this.emit('exit', { sessionId: session.id, session });
892
+ this.removeSession(session);
810
893
  }
811
894
 
812
895
  markSessionInput(session, data) {
813
896
  if (typeof data !== 'string' || data.length === 0) return;
814
897
  session.inputSeq = (session.inputSeq || 0) + 1;
815
- if (['claude-structured', 'codex-structured'].includes(session.kind)) {
898
+ if (isStructuredSession(session)) {
816
899
  session.hasUnreadCompletion = false;
817
900
  this.logSessionDiagnostics('session-input', session, {
818
901
  inputSeq: session.inputSeq,
@@ -831,7 +914,7 @@ class SessionManager extends EventEmitter {
831
914
  }
832
915
 
833
916
  disposeSessionHistory(session) {
834
- if (['claude-structured', 'codex-structured'].includes(session.kind)) return;
917
+ if (isStructuredSession(session)) return;
835
918
  if (session.renderedHistory) {
836
919
  session.renderedHistory.dispose();
837
920
  session.renderedHistory = null;
@@ -855,7 +938,7 @@ class SessionManager extends EventEmitter {
855
938
  }
856
939
 
857
940
  getSessionDiagnostics(session, extra = {}) {
858
- if (['claude-structured', 'codex-structured'].includes(session.kind)) {
941
+ if (isStructuredSession(session)) {
859
942
  return {
860
943
  sessionId: session.id,
861
944
  sessionName: session.name,
@@ -884,7 +967,7 @@ class SessionManager extends EventEmitter {
884
967
  }
885
968
 
886
969
  getCompactSessionDiagnostics(session, extra = {}) {
887
- if (['claude-structured', 'codex-structured'].includes(session.kind)) {
970
+ if (isStructuredSession(session)) {
888
971
  return this.getSessionDiagnostics(session, extra);
889
972
  }
890
973
  const buffer = session.buffer.getDebugSnapshot();