glad-web 1.0.29 → 1.0.31
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/lib/claude/cli-usage.js +95 -0
- package/lib/claude/structured-session.js +223 -51
- package/lib/claude/transcript-repository.js +216 -0
- package/lib/codex/image-store.js +175 -0
- package/lib/codex/structured-session.js +64 -7
- package/lib/commands/web.js +59 -234
- package/lib/server/routes/providers.js +103 -0
- package/lib/server/routes/schedules.js +54 -0
- package/lib/server/routes/workspace.js +77 -0
- package/lib/session/session-manager.js +117 -340
- package/lib/web/claude.js +1074 -0
- package/lib/web/codex.js +519 -0
- package/lib/web/composer.js +230 -0
- package/lib/web/core.js +327 -0
- package/lib/web/git.js +533 -0
- package/lib/web/index.html +46 -3672
- package/lib/web/schedules.js +245 -0
- package/lib/web/session.js +351 -0
- package/lib/web/shell.js +56 -0
- package/lib/web/styles.css +388 -0
- package/lib/web/terminal-scroll.js +81 -0
- package/lib/web/timed-inputs.js +223 -0
- package/lib/workspace/service.js +3 -2
- package/package.json +10 -5
- package/scripts/check-syntax.js +26 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { v4: uuidv4 } = require('uuid');
|
|
5
|
+
|
|
6
|
+
function previewText(text, maxChars) {
|
|
7
|
+
const normalized = String(text || '').replace(/\s+/g, ' ').trim();
|
|
8
|
+
return normalized.length > maxChars ? `${normalized.slice(0, maxChars)}...` : normalized;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isLocalCommandTranscript(text) {
|
|
12
|
+
const value = String(text || '').trim();
|
|
13
|
+
return value.startsWith('<command-name>/') || value.startsWith('<local-command-caveat>');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class ClaudeTranscriptRepository {
|
|
17
|
+
constructor({ baseDir, logger } = {}) {
|
|
18
|
+
this.baseDir = baseDir || process.cwd();
|
|
19
|
+
this.logger = logger || console;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
list(workingDirectory) {
|
|
23
|
+
const projectDir = this.getProjectDirectory(workingDirectory);
|
|
24
|
+
if (!fs.existsSync(projectDir)) return [];
|
|
25
|
+
const files = fs.readdirSync(projectDir)
|
|
26
|
+
.filter(file => /^[0-9a-f-]{36}\.jsonl$/i.test(file))
|
|
27
|
+
.map(file => {
|
|
28
|
+
const fullPath = path.join(projectDir, file);
|
|
29
|
+
const stat = fs.statSync(fullPath);
|
|
30
|
+
return {
|
|
31
|
+
id: file.replace(/\.jsonl$/i, ''),
|
|
32
|
+
path: fullPath,
|
|
33
|
+
mtimeMs: stat.mtimeMs,
|
|
34
|
+
size: stat.size
|
|
35
|
+
};
|
|
36
|
+
})
|
|
37
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
38
|
+
.slice(0, 40);
|
|
39
|
+
|
|
40
|
+
return files.map(file => this.describe(file));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
readMessages(workingDirectory, resumeSessionId) {
|
|
44
|
+
const id = String(resumeSessionId || '').trim();
|
|
45
|
+
if (!/^[0-9a-f-]{36}$/i.test(id)) return [];
|
|
46
|
+
const filePath = path.join(this.getProjectDirectory(workingDirectory), `${id}.jsonl`);
|
|
47
|
+
if (!fs.existsSync(filePath)) return [];
|
|
48
|
+
|
|
49
|
+
const messages = [];
|
|
50
|
+
try {
|
|
51
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
const record = this.parseLine(line);
|
|
54
|
+
if (!record || record.isSidechain) continue;
|
|
55
|
+
messages.push(...this.mapRecord(record));
|
|
56
|
+
}
|
|
57
|
+
} catch (error) {
|
|
58
|
+
this.logger.debugInfo?.(`[claude-resume] Failed to backfill ${filePath}: ${error.message}`);
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const maxMessages = 1000;
|
|
63
|
+
const visible = messages.filter(Boolean).slice(-maxMessages);
|
|
64
|
+
if (messages.length > maxMessages) {
|
|
65
|
+
visible.unshift({
|
|
66
|
+
id: uuidv4(),
|
|
67
|
+
kind: 'event',
|
|
68
|
+
level: 'info',
|
|
69
|
+
text: `Showing the latest ${maxMessages} resumed transcript items.`,
|
|
70
|
+
createdAt: Date.now()
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return visible;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getProjectDirectory(workingDirectory) {
|
|
77
|
+
const cwd = path.resolve(workingDirectory || this.baseDir);
|
|
78
|
+
const encoded = cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
|
79
|
+
return path.join(os.homedir(), '.claude', 'projects', encoded);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe(file) {
|
|
83
|
+
let cwd = '';
|
|
84
|
+
let firstText = '';
|
|
85
|
+
let lastText = '';
|
|
86
|
+
const userQuestions = [];
|
|
87
|
+
try {
|
|
88
|
+
const lines = fs.readFileSync(file.path, 'utf8').split('\n').filter(Boolean);
|
|
89
|
+
for (const line of lines) {
|
|
90
|
+
const parsed = this.parseLine(line);
|
|
91
|
+
if (!parsed) continue;
|
|
92
|
+
if (!cwd && typeof parsed.cwd === 'string') cwd = parsed.cwd;
|
|
93
|
+
const userText = this.extractUserText(parsed);
|
|
94
|
+
if (userText && userQuestions.at(-1) !== userText) userQuestions.push(userText);
|
|
95
|
+
const text = this.extractText(parsed);
|
|
96
|
+
if (!text) continue;
|
|
97
|
+
if (!firstText) firstText = text;
|
|
98
|
+
lastText = text;
|
|
99
|
+
}
|
|
100
|
+
} catch (error) {
|
|
101
|
+
this.logger.debugInfo?.(`[claude-resume] Failed to read ${file.path}: ${error.message}`);
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
id: file.id,
|
|
105
|
+
cwd,
|
|
106
|
+
updatedAt: file.mtimeMs,
|
|
107
|
+
size: file.size,
|
|
108
|
+
questions: userQuestions.slice(-2).reverse().map(text => previewText(text, 120)),
|
|
109
|
+
firstText: firstText ? previewText(firstText, 120) : '',
|
|
110
|
+
lastText: lastText ? previewText(lastText, 160) : ''
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
mapRecord(record) {
|
|
115
|
+
const createdAt = Number.isFinite(Date.parse(record.timestamp)) ? Date.parse(record.timestamp) : Date.now();
|
|
116
|
+
const message = record.message || {};
|
|
117
|
+
const content = message.content;
|
|
118
|
+
if (record.type === 'user') {
|
|
119
|
+
if (typeof content === 'string') {
|
|
120
|
+
const text = content.trim();
|
|
121
|
+
return text && !isLocalCommandTranscript(text) ? [{ id: uuidv4(), kind: 'user', text, createdAt }] : [];
|
|
122
|
+
}
|
|
123
|
+
if (Array.isArray(content)) {
|
|
124
|
+
return content.flatMap(item => {
|
|
125
|
+
if (!item || typeof item !== 'object') return [];
|
|
126
|
+
if (item.type === 'tool_result') {
|
|
127
|
+
const text = this.textFromContent(item.content).trim();
|
|
128
|
+
return text ? [{
|
|
129
|
+
id: uuidv4(), kind: 'tool-result', toolUseId: item.tool_use_id,
|
|
130
|
+
text, isError: Boolean(item.is_error), createdAt
|
|
131
|
+
}] : [];
|
|
132
|
+
}
|
|
133
|
+
if (item.type === 'text' && typeof item.text === 'string' && item.text.trim()) {
|
|
134
|
+
const text = item.text.trim();
|
|
135
|
+
return isLocalCommandTranscript(text) ? [] : [{ id: uuidv4(), kind: 'user', text, createdAt }];
|
|
136
|
+
}
|
|
137
|
+
return [];
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (record.type === 'assistant' && Array.isArray(content)) {
|
|
143
|
+
const mapped = [];
|
|
144
|
+
const text = this.textFromContent(content).trim();
|
|
145
|
+
if (text) mapped.push({ id: uuidv4(), kind: 'assistant', text, createdAt });
|
|
146
|
+
for (const item of content) {
|
|
147
|
+
if (!item || item.type !== 'tool_use') continue;
|
|
148
|
+
mapped.push({
|
|
149
|
+
id: uuidv4(), kind: 'tool', name: item.name || 'tool',
|
|
150
|
+
summary: this.summarizeToolInput(item.input), input: item.input,
|
|
151
|
+
toolUseId: item.id, createdAt
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return mapped;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (record.type === 'summary' && typeof record.summary === 'string' && record.summary.trim()) {
|
|
158
|
+
return [{ id: uuidv4(), kind: 'event', level: 'info', text: `Summary: ${record.summary.trim()}`, createdAt }];
|
|
159
|
+
}
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
textFromContent(content) {
|
|
164
|
+
if (typeof content === 'string') return content;
|
|
165
|
+
if (!Array.isArray(content)) return '';
|
|
166
|
+
return content.map(item => {
|
|
167
|
+
if (!item || typeof item !== 'object') return '';
|
|
168
|
+
if (item.type === 'text' && typeof item.text === 'string') return item.text;
|
|
169
|
+
if (item.type === 'tool_result') return this.textFromContent(item.content);
|
|
170
|
+
return '';
|
|
171
|
+
}).filter(Boolean).join('\n');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
summarizeToolInput(input) {
|
|
175
|
+
if (!input || typeof input !== 'object') return '';
|
|
176
|
+
if (typeof input.command === 'string') return input.command;
|
|
177
|
+
if (typeof input.file_path === 'string') return input.file_path;
|
|
178
|
+
if (typeof input.path === 'string') return input.path;
|
|
179
|
+
const serialized = JSON.stringify(input);
|
|
180
|
+
return serialized.length > 240 ? `${serialized.slice(0, 240)}...` : serialized;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
parseLine(line) {
|
|
184
|
+
try {
|
|
185
|
+
return JSON.parse(line);
|
|
186
|
+
} catch (_) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
extractText(record) {
|
|
192
|
+
const content = record?.message?.content;
|
|
193
|
+
if (typeof content === 'string') return content.trim();
|
|
194
|
+
if (!Array.isArray(content)) return '';
|
|
195
|
+
return content.map(item => {
|
|
196
|
+
if (!item || typeof item !== 'object') return '';
|
|
197
|
+
if (item.type === 'text' && typeof item.text === 'string') return item.text;
|
|
198
|
+
if (item.type === 'tool_use') return `[tool] ${item.name || 'tool'}`;
|
|
199
|
+
return '';
|
|
200
|
+
}).filter(Boolean).join('\n').trim();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
extractUserText(record) {
|
|
204
|
+
if (record?.type !== 'user') return '';
|
|
205
|
+
const content = record?.message?.content;
|
|
206
|
+
if (typeof content === 'string') {
|
|
207
|
+
const text = content.trim();
|
|
208
|
+
return isLocalCommandTranscript(text) ? '' : text;
|
|
209
|
+
}
|
|
210
|
+
if (!Array.isArray(content)) return '';
|
|
211
|
+
return content.map(item => item?.type === 'text' && typeof item.text === 'string' && !isLocalCommandTranscript(item.text) ? item.text : '')
|
|
212
|
+
.filter(Boolean).join('\n').trim();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = ClaudeTranscriptRepository;
|
|
@@ -0,0 +1,175 @@
|
|
|
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 = 5;
|
|
8
|
+
const CLEANUP_DELAY_MS = 5 * 60 * 1000;
|
|
9
|
+
const MAX_CHUNKS = 128;
|
|
10
|
+
|
|
11
|
+
function imageExtension(buffer) {
|
|
12
|
+
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'png';
|
|
13
|
+
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'jpg';
|
|
14
|
+
if (buffer.length >= 6 && ['GIF87a', 'GIF89a'].includes(buffer.subarray(0, 6).toString('ascii'))) return 'gif';
|
|
15
|
+
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') return 'webp';
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function inputError(message, statusCode = 400) {
|
|
20
|
+
const error = new Error(message);
|
|
21
|
+
error.statusCode = statusCode;
|
|
22
|
+
return error;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function safeUploadId(value) {
|
|
26
|
+
return typeof value === 'string' && /^[a-zA-Z0-9-]{8,100}$/.test(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class CodexImageStore {
|
|
30
|
+
constructor({ logger, root, uploadRoot } = {}) {
|
|
31
|
+
this.logger = logger || console;
|
|
32
|
+
this.root = root || path.join(os.tmpdir(), 'glad', 'codex-images');
|
|
33
|
+
this.uploadRoot = uploadRoot || path.join(os.tmpdir(), 'glad', 'codex-image-uploads');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
assertSession(session) {
|
|
37
|
+
if (!session) throw inputError('Session not found', 404);
|
|
38
|
+
const structured = session.kind === 'claude-structured'
|
|
39
|
+
|| (session.kind === 'codex-structured' && session.presentation === 'structured');
|
|
40
|
+
if (!structured) {
|
|
41
|
+
throw inputError('Image attachments are available only in structured chat mode');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async store(session, bytes) {
|
|
46
|
+
this.assertSession(session);
|
|
47
|
+
if (!Buffer.isBuffer(bytes) || bytes.length === 0) throw inputError('Image data is required');
|
|
48
|
+
if (bytes.length > MAX_BYTES) throw inputError('Image must be 50 MB or smaller');
|
|
49
|
+
if (session.imageAttachments.size >= MAX_PER_SESSION) throw inputError(`You can attach at most ${MAX_PER_SESSION} images at a time`);
|
|
50
|
+
|
|
51
|
+
const extension = imageExtension(bytes);
|
|
52
|
+
if (!extension) throw inputError('Only PNG, JPEG, GIF, and WebP images are supported');
|
|
53
|
+
const directory = path.join(this.root, session.id);
|
|
54
|
+
await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
55
|
+
const attachment = {
|
|
56
|
+
id: uuidv4(),
|
|
57
|
+
name: `image.${extension}`,
|
|
58
|
+
path: path.join(directory, `${uuidv4()}.${extension}`),
|
|
59
|
+
size: bytes.length,
|
|
60
|
+
createdAt: Date.now(),
|
|
61
|
+
cleanupTimer: null
|
|
62
|
+
};
|
|
63
|
+
await fs.promises.writeFile(attachment.path, bytes, { mode: 0o600, flag: 'wx' });
|
|
64
|
+
session.imageAttachments.set(attachment.id, attachment);
|
|
65
|
+
return { id: attachment.id, name: attachment.name, size: attachment.size };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async appendChunk(session, input = {}, bytes) {
|
|
69
|
+
this.assertSession(session);
|
|
70
|
+
if (!Buffer.isBuffer(bytes) || bytes.length === 0) throw inputError('Image chunk is required');
|
|
71
|
+
const uploadId = String(input.uploadId || '');
|
|
72
|
+
const chunkIndex = Number(input.chunkIndex);
|
|
73
|
+
const chunkTotal = Number(input.chunkTotal);
|
|
74
|
+
if (!safeUploadId(uploadId)) throw inputError('Invalid image upload id');
|
|
75
|
+
if (!Number.isInteger(chunkIndex) || !Number.isInteger(chunkTotal) || chunkIndex < 0 || chunkTotal < 1 || chunkTotal > MAX_CHUNKS || chunkIndex >= chunkTotal) {
|
|
76
|
+
throw inputError('Invalid image chunk metadata');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let upload = session.imageUploads.get(uploadId);
|
|
80
|
+
if (!upload) {
|
|
81
|
+
if (chunkIndex !== 0) throw inputError('Image upload must start with the first chunk');
|
|
82
|
+
const directory = path.join(this.uploadRoot, session.id);
|
|
83
|
+
await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
84
|
+
upload = { id: uploadId, path: path.join(directory, `${uploadId}.part`), chunkTotal, nextChunkIndex: 0, bytes: 0 };
|
|
85
|
+
session.imageUploads.set(uploadId, upload);
|
|
86
|
+
}
|
|
87
|
+
if (upload.chunkTotal !== chunkTotal || upload.nextChunkIndex !== chunkIndex) throw inputError('Image chunks arrived out of order');
|
|
88
|
+
if (upload.bytes + bytes.length > MAX_BYTES) {
|
|
89
|
+
await this.discardUpload(session, uploadId);
|
|
90
|
+
throw inputError('Image must be 50 MB or smaller');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (chunkIndex === 0) await fs.promises.writeFile(upload.path, bytes, { mode: 0o600, flag: 'wx' });
|
|
94
|
+
else await fs.promises.appendFile(upload.path, bytes, { mode: 0o600 });
|
|
95
|
+
upload.bytes += bytes.length;
|
|
96
|
+
upload.nextChunkIndex += 1;
|
|
97
|
+
if (upload.nextChunkIndex < upload.chunkTotal) {
|
|
98
|
+
return { complete: false, receivedChunks: upload.nextChunkIndex, size: upload.bytes };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
session.imageUploads.delete(uploadId);
|
|
102
|
+
try {
|
|
103
|
+
const attachment = await this.store(session, await fs.promises.readFile(upload.path));
|
|
104
|
+
return { complete: true, attachment };
|
|
105
|
+
} finally {
|
|
106
|
+
await fs.promises.rm(upload.path, { force: true });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async discardUpload(session, uploadId) {
|
|
111
|
+
if (!session?.imageUploads || !safeUploadId(uploadId)) return false;
|
|
112
|
+
const upload = session.imageUploads.get(uploadId);
|
|
113
|
+
if (!upload) return false;
|
|
114
|
+
session.imageUploads.delete(uploadId);
|
|
115
|
+
await fs.promises.rm(upload.path, { force: true });
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async discardAttachment(session, attachmentId) {
|
|
120
|
+
if (!session || !['claude-structured', 'codex-structured'].includes(session.kind)) return false;
|
|
121
|
+
const attachment = session.imageAttachments.get(attachmentId);
|
|
122
|
+
if (!attachment) return false;
|
|
123
|
+
clearTimeout(attachment.cleanupTimer);
|
|
124
|
+
session.imageAttachments.delete(attachmentId);
|
|
125
|
+
await fs.promises.rm(attachment.path, { force: true });
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
resolve(session, attachmentIds = []) {
|
|
130
|
+
if (!session || !['claude-structured', 'codex-structured'].includes(session.kind)) throw inputError('Structured session not found', 404);
|
|
131
|
+
const ids = Array.isArray(attachmentIds) ? attachmentIds : [];
|
|
132
|
+
if (ids.length > MAX_PER_SESSION) throw inputError(`You can attach at most ${MAX_PER_SESSION} images at a time`);
|
|
133
|
+
const uniqueIds = [...new Set(ids.map(String))];
|
|
134
|
+
if (uniqueIds.length !== ids.length) throw inputError('Duplicate image attachment');
|
|
135
|
+
return uniqueIds.map(attachmentId => {
|
|
136
|
+
const attachment = session.imageAttachments.get(attachmentId);
|
|
137
|
+
if (!attachment) throw inputError('Image attachment is no longer available');
|
|
138
|
+
return attachment;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
scheduleCleanup(session, attachmentIds) {
|
|
143
|
+
if (!session || !['claude-structured', 'codex-structured'].includes(session.kind)) return;
|
|
144
|
+
for (const attachmentId of attachmentIds) {
|
|
145
|
+
const attachment = session.imageAttachments.get(attachmentId);
|
|
146
|
+
if (!attachment) continue;
|
|
147
|
+
clearTimeout(attachment.cleanupTimer);
|
|
148
|
+
attachment.cleanupTimer = setTimeout(() => {
|
|
149
|
+
this.discardAttachment(session, attachmentId).catch(error => {
|
|
150
|
+
this.logger.debugInfo?.(`[structured-image] cleanup failed: ${error.message}`);
|
|
151
|
+
});
|
|
152
|
+
}, CLEANUP_DELAY_MS);
|
|
153
|
+
attachment.cleanupTimer.unref?.();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
clearAttachments(session) {
|
|
158
|
+
if (!session?.imageAttachments) return;
|
|
159
|
+
for (const attachment of session.imageAttachments.values()) clearTimeout(attachment.cleanupTimer);
|
|
160
|
+
session.imageAttachments.clear();
|
|
161
|
+
fs.promises.rm(path.join(this.root, session.id), { recursive: true, force: true }).catch(error => {
|
|
162
|
+
this.logger.debugInfo?.(`[structured-image] session cleanup failed: ${error.message}`);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
clearUploads(session) {
|
|
167
|
+
if (!session?.imageUploads) return;
|
|
168
|
+
session.imageUploads.clear();
|
|
169
|
+
fs.promises.rm(path.join(this.uploadRoot, session.id), { recursive: true, force: true }).catch(error => {
|
|
170
|
+
this.logger.debugInfo?.(`[structured-image] upload cleanup failed: ${error.message}`);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = CodexImageStore;
|
|
@@ -167,8 +167,10 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
167
167
|
this.currentTurnId = null;
|
|
168
168
|
this.currentTurnStartedAt = null;
|
|
169
169
|
this.threadTurns = new Map();
|
|
170
|
+
this.turnContexts = new Map();
|
|
170
171
|
this.providerItemContexts = new Map();
|
|
171
172
|
this.tokenUsage = null;
|
|
173
|
+
this.compacting = false;
|
|
172
174
|
this.permissionMode = normalizePermissionMode(options.permissionMode);
|
|
173
175
|
this.sandboxMode = normalizeSandboxMode(options.sandboxMode);
|
|
174
176
|
this.effectivePermissionMode = null;
|
|
@@ -226,6 +228,8 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
226
228
|
model: this.model, effort: this.effort,
|
|
227
229
|
status: this.status, threadId: this.threadId, presentation: this.presentation,
|
|
228
230
|
canAbort: this.presentation === 'structured' && this.status !== 'idle',
|
|
231
|
+
canCompact: this.presentation === 'structured' && this.status === 'idle' && !this.compacting && Boolean(this.threadId),
|
|
232
|
+
compacting: this.compacting,
|
|
229
233
|
canSwitchToTerminal: this.presentation === 'structured' && this.status === 'idle' && Boolean(this.threadId),
|
|
230
234
|
canSwitchToStructured: this.presentation === 'terminal',
|
|
231
235
|
pendingPermissionCount: this.pendingPermissions.size, activeSubagentCount, models: this.models };
|
|
@@ -289,6 +293,7 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
289
293
|
for (const request of this.pendingRequests.values()) request.reject(new Error(`Codex app-server exited (${code})`));
|
|
290
294
|
this.pendingRequests.clear();
|
|
291
295
|
if (this.running && this.presentation === 'structured') {
|
|
296
|
+
this.compacting = false;
|
|
292
297
|
this.append({ kind: 'event', level: 'error', text: 'Codex app-server exited.' });
|
|
293
298
|
this.setStatus('idle');
|
|
294
299
|
}
|
|
@@ -382,6 +387,7 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
382
387
|
handleNotification(method, params) {
|
|
383
388
|
if (method === 'thread/tokenUsage/updated') {
|
|
384
389
|
this.tokenUsage = params.tokenUsage || params.usage || params;
|
|
390
|
+
this.recordTurnContext(params.turnId, this.tokenUsage);
|
|
385
391
|
return;
|
|
386
392
|
}
|
|
387
393
|
if (method === 'turn/started') {
|
|
@@ -411,8 +417,9 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
411
417
|
const startedAtMs = trackedTurn?.startedAt || ((!threadId || threadId === this.threadId) ? this.currentTurnStartedAt : null);
|
|
412
418
|
const durationMs = Number(params.turn?.durationMs || 0)
|
|
413
419
|
|| (startedAtMs ? Math.max(0, completedAtMs - startedAtMs) : null);
|
|
420
|
+
const context = this.turnContexts.get(String(completedTurnId || ''));
|
|
414
421
|
this.append({ kind: 'turn-end', threadId, turnId: completedTurnId, status: turnStatus,
|
|
415
|
-
durationMs, createdAt: completedAtMs });
|
|
422
|
+
durationMs, createdAt: completedAtMs, ...(context ? { context } : {}) });
|
|
416
423
|
const observedNow = Date.now();
|
|
417
424
|
const observedCompletedAtMs = Math.abs(observedNow - completedAtMs) < 5000
|
|
418
425
|
? Math.max(completedAtMs, observedNow) : completedAtMs;
|
|
@@ -424,8 +431,13 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
424
431
|
this.patch(item.id, { toolStatus,
|
|
425
432
|
completedAtMs: observedCompletedAtMs, ...(toolDurationMs != null ? { durationMs: toolDurationMs } : {}) });
|
|
426
433
|
}
|
|
434
|
+
for (const item of this.messages.filter(message => message.kind === 'compaction'
|
|
435
|
+
&& message.turnId === completedTurnId && message.compactionStatus === 'running')) {
|
|
436
|
+
this.patch(item.id, { compactionStatus: 'completed', completedAtMs: observedCompletedAtMs });
|
|
437
|
+
}
|
|
427
438
|
if (threadId) this.threadTurns.delete(threadId);
|
|
428
439
|
if (!threadId || threadId === this.threadId) {
|
|
440
|
+
this.compacting = false;
|
|
429
441
|
for (const pending of this.pendingPermissions.values()) {
|
|
430
442
|
this.recordPermission(pending.public, 'denied', 'abort');
|
|
431
443
|
}
|
|
@@ -442,6 +454,14 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
442
454
|
}
|
|
443
455
|
return;
|
|
444
456
|
}
|
|
457
|
+
if (method === 'thread/compacted') {
|
|
458
|
+
const threadId = params.threadId || this.threadId;
|
|
459
|
+
const turnId = params.turnId || (threadId ? this.threadTurns.get(threadId)?.turnId : null) || this.currentTurnId;
|
|
460
|
+
this.applyProviderItem({ id: `compaction-${turnId || Date.now()}`, type: 'contextCompaction', threadId, turnId }, 'completed', {
|
|
461
|
+
threadId, turnId, completedAtMs: Date.now()
|
|
462
|
+
});
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
445
465
|
if (method === 'thread/started' || method === 'thread/resumed') {
|
|
446
466
|
const threadId = params.thread?.id || params.threadId;
|
|
447
467
|
if (threadId && !this.threadId) { this.threadId = threadId; this.emitControlState(); }
|
|
@@ -451,7 +471,7 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
451
471
|
const threadId = params.threadId || this.threadId;
|
|
452
472
|
const status = params.status?.type || params.status;
|
|
453
473
|
if (!threadId || threadId === this.threadId) {
|
|
454
|
-
if (status === 'idle' && !this.currentTurnId) this.setStatus('idle');
|
|
474
|
+
if (status === 'idle' && !this.currentTurnId) { this.compacting = false; this.setStatus('idle'); }
|
|
455
475
|
if (status === 'active') this.setStatus('running');
|
|
456
476
|
}
|
|
457
477
|
return;
|
|
@@ -467,7 +487,7 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
467
487
|
}
|
|
468
488
|
if (method === 'error') {
|
|
469
489
|
this.append({ kind: 'event', level: 'error', text: params.error?.message || 'Codex reported an error.' });
|
|
470
|
-
if (!params.willRetry) this.setStatus('idle');
|
|
490
|
+
if (!params.willRetry) { this.compacting = false; this.setStatus('idle'); }
|
|
471
491
|
return;
|
|
472
492
|
}
|
|
473
493
|
if (method === 'warning' || method === 'guardianWarning') {
|
|
@@ -519,9 +539,10 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
519
539
|
applyProviderItem(raw, inferredStatus = null, context = {}) {
|
|
520
540
|
if (!raw || typeof raw !== 'object') return;
|
|
521
541
|
const providerId = String(raw.id || '');
|
|
522
|
-
|
|
542
|
+
let existing = providerId && this.messages.find(item => item.providerId === providerId);
|
|
523
543
|
const kind = raw.type === 'userMessage' ? 'user' : raw.type === 'agentMessage' ? 'assistant' : ['reasoning', 'plan'].includes(raw.type) ? 'reasoning'
|
|
524
|
-
: ['commandExecution', 'fileChange', 'mcpToolCall', 'dynamicToolCall', 'webSearch', 'collabAgentToolCall'].includes(raw.type) ? 'tool'
|
|
544
|
+
: ['commandExecution', 'fileChange', 'mcpToolCall', 'dynamicToolCall', 'webSearch', 'collabAgentToolCall'].includes(raw.type) ? 'tool'
|
|
545
|
+
: raw.type === 'contextCompaction' ? 'compaction' : null;
|
|
525
546
|
if (!kind) return;
|
|
526
547
|
const text = kind === 'user' ? textFromInputItems(raw.content) : kind === 'assistant' ? String(raw.text || '')
|
|
527
548
|
: kind === 'reasoning' ? (raw.text || (Array.isArray(raw.summary) ? raw.summary.join('\n') : Array.isArray(raw.content) ? raw.content.join('\n') : '')) : '';
|
|
@@ -530,6 +551,9 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
530
551
|
const threadId = raw.threadId || context.threadId || null;
|
|
531
552
|
const trackedTurnId = threadId ? this.threadTurns.get(threadId)?.turnId : null;
|
|
532
553
|
const turnId = raw.turnId || context.turnId || trackedTurnId || this.currentTurnId;
|
|
554
|
+
if (!existing && kind === 'compaction' && turnId) {
|
|
555
|
+
existing = this.messages.find(item => item.kind === 'compaction' && item.turnId === turnId);
|
|
556
|
+
}
|
|
533
557
|
if (providerId && (threadId || turnId)) this.providerItemContexts.set(providerId, { threadId, turnId });
|
|
534
558
|
const existingStartedAtMs = existing?.startedAtMs || existing?.createdAt || null;
|
|
535
559
|
const startedAtMs = Number(context.startedAtMs || raw.startedAtMs || 0)
|
|
@@ -546,6 +570,8 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
546
570
|
};
|
|
547
571
|
const patch = kind === 'tool' ? { ...toolDetails(raw), threadId, turnId,
|
|
548
572
|
...timing, toolStatus: inferredToolStatus || raw.status || 'running' }
|
|
573
|
+
: kind === 'compaction' ? { providerId, threadId, turnId, ...timing,
|
|
574
|
+
compactionStatus: inferredStatus || raw.status || 'running' }
|
|
549
575
|
: { text, threadId, turnId, streaming: false, ...(completedAtMs ? { completedAtMs } : {}) };
|
|
550
576
|
if (existing) {
|
|
551
577
|
this.patch(existing.id, patch);
|
|
@@ -556,6 +582,10 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
556
582
|
} else {
|
|
557
583
|
this.append({ kind, providerId, ...(startedAtMs ? { createdAt: startedAtMs } : {}), ...patch });
|
|
558
584
|
}
|
|
585
|
+
if (kind === 'compaction' && (!threadId || threadId === this.threadId)) {
|
|
586
|
+
this.compacting = patch.compactionStatus === 'running';
|
|
587
|
+
this.emitControlState();
|
|
588
|
+
}
|
|
559
589
|
}
|
|
560
590
|
|
|
561
591
|
async refreshModels() {
|
|
@@ -625,8 +655,8 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
625
655
|
return items;
|
|
626
656
|
}
|
|
627
657
|
|
|
628
|
-
contextStatus() {
|
|
629
|
-
const usage =
|
|
658
|
+
contextStatus(tokenUsage = this.tokenUsage) {
|
|
659
|
+
const usage = tokenUsage || {};
|
|
630
660
|
const selectedModel = this.models.find(item => item.id === this.model);
|
|
631
661
|
const contextWindow = Number(usage.modelContextWindow || usage.model_context_window
|
|
632
662
|
|| usage.contextWindow || usage.context_window || selectedModel?.contextWindow || 0);
|
|
@@ -646,6 +676,16 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
646
676
|
};
|
|
647
677
|
}
|
|
648
678
|
|
|
679
|
+
recordTurnContext(turnId, tokenUsage = this.tokenUsage) {
|
|
680
|
+
const id = String(turnId || '').trim();
|
|
681
|
+
const context = this.contextStatus(tokenUsage);
|
|
682
|
+
if (!id || !context) return context;
|
|
683
|
+
this.turnContexts.set(id, context);
|
|
684
|
+
const turnEnd = this.messages.find(item => item.kind === 'turn-end' && String(item.turnId || '') === id);
|
|
685
|
+
if (turnEnd) this.patch(turnEnd.id, { context });
|
|
686
|
+
return context;
|
|
687
|
+
}
|
|
688
|
+
|
|
649
689
|
async showStatus() {
|
|
650
690
|
if (this.presentation !== 'structured') return false;
|
|
651
691
|
await this.ensureProcess();
|
|
@@ -759,6 +799,22 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
759
799
|
}
|
|
760
800
|
}
|
|
761
801
|
|
|
802
|
+
async compactContext() {
|
|
803
|
+
if (!this.threadId || this.presentation !== 'structured' || this.status !== 'idle') return false;
|
|
804
|
+
await this.ensureProcess();
|
|
805
|
+
this.compacting = true;
|
|
806
|
+
this.setStatus('running');
|
|
807
|
+
try {
|
|
808
|
+
await this.request('thread/compact/start', { threadId: this.threadId });
|
|
809
|
+
return true;
|
|
810
|
+
} catch (error) {
|
|
811
|
+
this.compacting = false;
|
|
812
|
+
this.setStatus('idle');
|
|
813
|
+
this.append({ kind: 'event', level: 'error', text: `Unable to compact context: ${error.message}` });
|
|
814
|
+
throw error;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
762
818
|
write(data) {
|
|
763
819
|
if (this.presentation === 'terminal') return this.terminalSession?.write(data) || false;
|
|
764
820
|
const text = String(data || '').replace(/\r/g, '\n');
|
|
@@ -848,6 +904,7 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
848
904
|
this.tokenUsage = thread?.tokenUsage || thread?.token_usage || this.tokenUsage;
|
|
849
905
|
this.messages = [];
|
|
850
906
|
this.completedPermissions = [];
|
|
907
|
+
this.turnContexts.clear();
|
|
851
908
|
this.providerItemContexts.clear();
|
|
852
909
|
for (const turn of thread?.turns || []) {
|
|
853
910
|
const status = turn.status === 'failed' ? 'failed' : turn.status === 'interrupted' ? 'cancelled' : 'completed';
|