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.
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/lib/claude/structured-session.js +10 -5
- package/lib/codex/image-store.js +1 -2
- package/lib/codex/structured-session.js +39 -76
- package/lib/commands/web.js +64 -9
- package/lib/config/manager.js +19 -0
- package/lib/server/routes/providers.js +0 -12
- package/lib/server/routes/skillhub.js +104 -0
- package/lib/session/file-attachment-store.js +168 -0
- package/lib/session/session-manager.js +143 -60
- package/lib/skillhub/client.js +121 -0
- package/lib/skillhub/settings-store.js +168 -0
- package/lib/skillhub/skill-installer.js +320 -0
- package/lib/web/bootstrap.js +34 -0
- package/lib/web/claude.js +44 -16
- package/lib/web/codex.js +51 -22
- package/lib/web/composer.js +293 -28
- package/lib/web/core.js +56 -38
- package/lib/web/git.js +53 -51
- package/lib/web/gitgraph.js +8 -8
- package/lib/web/index.html +61 -26
- package/lib/web/layout.js +4 -7
- package/lib/web/notifications.js +1 -0
- package/lib/web/session.js +7 -21
- package/lib/web/shell.js +17 -2
- package/lib/web/skillhub.js +197 -0
- package/lib/web/styles.css +138 -16
- package/lib/web/timed-inputs.js +6 -6
- package/package.json +6 -3
|
@@ -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:
|
|
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({
|
|
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 (
|
|
256
|
+
if (isStructuredSession(session)) return session.write(data);
|
|
228
257
|
return session.ptyManager.write(data);
|
|
229
258
|
}
|
|
230
259
|
|
|
231
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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.
|
|
413
|
-
const error = new Error('Codex must be idle
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async stopSessionRuntime(session) {
|
|
698
|
+
if (isStructuredSession(session)) {
|
|
699
|
+
await session.kill();
|
|
700
|
+
return;
|
|
625
701
|
}
|
|
626
702
|
session.ptyManager.kill();
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
635
|
-
this.
|
|
636
|
-
|
|
637
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
890
|
+
if (this.sessions.get(session.id) !== session) return;
|
|
802
891
|
this.logger.info(`Session ${session.id} (${session.name}) exited.`);
|
|
803
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
970
|
+
if (isStructuredSession(session)) {
|
|
888
971
|
return this.getSessionDiagnostics(session, extra);
|
|
889
972
|
}
|
|
890
973
|
const buffer = session.buffer.getDebugSnapshot();
|