glad-web 1.0.44 → 1.0.45

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 CHANGED
@@ -34,6 +34,8 @@ Glad was created to enable **vibe coding** on mobile devices. By bringing variou
34
34
 
35
35
  Our design philosophy is **Easy to use, Stable, and Restrained**. Glad focuses strictly on the essentials:
36
36
  - **Session management:** Run multiple sessions from a single dashboard with per-session working directories.
37
+ - **Responsive workspace:** Use a resizable session sidebar on wide screens and focused lobby/chat pages on mobile, with light and dark themes.
38
+ - **File attachments:** Upload images or arbitrary local files from the composer; Glad stores them privately for the active session and cleans them up automatically.
37
39
  - **Local usage dashboard:** Select a week or month, compare per-model and daily token totals, and inspect model-stacked token and cost charts through the bundled read-only `ccusage` engine. Costs use `ccusage` estimates and are shown only for GPT models used by Codex.
38
40
  - **High-fidelity terminal interaction:** A mobile-friendly terminal experience with touch shortcuts.
39
41
  - **Extreme performance history viewing:** Fast and responsive text history.
package/README.zh-CN.md CHANGED
@@ -34,6 +34,8 @@ Glad 的初衷是开发一款完全运行在本地的、足够简单的,且登
34
34
 
35
35
  我们的设计哲学是:**易用、稳定、克制**。只提供最核心且体验优秀的功能:
36
36
  - **Session 管理**:在一个面板中管理多个会话,每个会话可单独指定工作目录。
37
+ - **响应式工作区**:宽屏提供可调宽度的会话侧边栏,手机使用大厅与对话双页面,并支持亮色、暗色主题。
38
+ - **文件附件**:可从输入区上传图片或普通文件;附件仅保存于当前会话的私有临时目录,并会自动清理。
37
39
  - **本地用量看板**:通过内置的只读 `ccusage` 引擎选择某周或某月,查看按模型汇总及每日 token,并用按模型堆叠的柱状图比较 token 和费用;费用完全采用 `ccusage` 估算,且只对 Codex 使用的 GPT 模型显示。
38
40
  - **高还原度的 terminal 交互**:专为手机优化的终端体验与快捷按键。
39
41
  - **极致性能的历史查看**:快速流畅的终端历史记录浏览。
@@ -211,22 +211,27 @@ class ClaudeStructuredSession extends EventEmitter {
211
211
  return true;
212
212
  }
213
213
 
214
- sendUserMessage(text, attachments = []) {
214
+ sendUserMessage(text, attachments = [], options = {}) {
215
215
  if (!this.running) return false;
216
216
  const prompt = String(text || '').trim();
217
+ const agentPrompt = String(options.agentText ?? prompt).trim();
217
218
  const images = Array.isArray(attachments) ? attachments.filter(item => item?.data && item?.mediaType) : [];
218
- if (!prompt && images.length === 0) return false;
219
+ const displayAttachments = Array.isArray(options.displayAttachments) ? options.displayAttachments : [];
220
+ if (!agentPrompt && images.length === 0) return false;
219
221
  this.hasUnreadCompletion = false;
220
- const turn = this.beginTurn(prompt, images.map(item => ({ name: item.name, size: item.size })));
222
+ const turn = this.beginTurn(prompt, [
223
+ ...images.map(item => ({ name: item.name, size: item.size })),
224
+ ...displayAttachments
225
+ ]);
221
226
  this.setStatus('thinking');
222
227
 
223
228
  const content = images.length > 0 ? [
224
- ...(prompt ? [{ type: 'text', text: prompt }] : []),
229
+ ...(agentPrompt ? [{ type: 'text', text: agentPrompt }] : []),
225
230
  ...images.map(item => ({
226
231
  type: 'image',
227
232
  source: { type: 'base64', media_type: item.mediaType, data: item.data }
228
233
  }))
229
- ] : prompt;
234
+ ] : agentPrompt;
230
235
 
231
236
  const sdkMessage = {
232
237
  type: 'user',
@@ -35,8 +35,7 @@ class CodexImageStore {
35
35
 
36
36
  assertSession(session) {
37
37
  if (!session) throw inputError('Session not found', 404);
38
- const structured = session.kind === 'claude-structured'
39
- || (session.kind === 'codex-structured' && session.presentation === 'structured');
38
+ const structured = ['claude-structured', 'codex-structured'].includes(session.kind);
40
39
  if (!structured) {
41
40
  throw inputError('Image attachments are available only in structured chat mode');
42
41
  }
@@ -3,7 +3,6 @@ const { spawn } = require('child_process');
3
3
  const net = require('net');
4
4
  const crypto = require('crypto');
5
5
  const WebSocket = require('ws');
6
- const PTYManager = require('../session/pty-manager');
7
6
 
8
7
  const PERMISSION_MODES = new Set(['untrusted', 'on-request', 'never']);
9
8
  const SANDBOX_MODES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
@@ -282,7 +281,6 @@ class CodexStructuredSession extends EventEmitter {
282
281
  this.startTime = Date.now();
283
282
  this.running = true;
284
283
  this.status = 'idle';
285
- this.presentation = 'structured';
286
284
  this.messages = [];
287
285
  this.replayingHistory = false;
288
286
  this.pendingPermissions = new Map();
@@ -326,27 +324,17 @@ class CodexStructuredSession extends EventEmitter {
326
324
  this.abortGraceMs = Number(options.abortGraceMs) > 0
327
325
  ? Number(options.abortGraceMs) : DEFAULT_ABORT_GRACE_MS;
328
326
  this.forceKillProcessTree = options.forceKillProcessTree || forceKillProcessTree;
329
- this.terminalSession = null;
330
- this.terminalOutput = '';
331
327
  this.hasUnreadCompletion = false;
332
328
  this.inputSeq = 0;
333
329
  this.completionReadInputSeq = 0;
334
330
  this.timedInputs = new Map();
335
331
  this.promptHistoryCache = null;
336
332
  this.deferredWarnings = null;
337
- this.ptyManager = {
338
- workingDir,
339
- isRunning: () => this.isRunning(),
340
- write: data => this.write(data),
341
- kill: () => this.kill(),
342
- resize: (cols, rows) => this.terminalSession?.resize(cols, rows),
343
- redraw: () => false
344
- };
345
333
  }
346
334
 
347
335
  toListItem() {
348
336
  return { id: this.id, name: this.name, tool: this.tool.displayName, startTime: this.startTime,
349
- toolKey: this.tool.key, workingDirectory: this.workingDir, mode: this.presentation === 'terminal' ? 'terminal' : 'structured',
337
+ toolKey: this.tool.key, workingDirectory: this.workingDir, mode: 'structured',
350
338
  hasUnreadCompletion: Boolean(this.hasUnreadCompletion), timedInputCount: this.timedInputs.size };
351
339
  }
352
340
 
@@ -404,15 +392,12 @@ class CodexStructuredSession extends EventEmitter {
404
392
  return { permissionMode: this.permissionMode || 'default', sandboxMode: this.sandboxMode || 'default',
405
393
  effectivePermissionMode: this.effectivePermissionMode, effectiveSandboxMode: this.effectiveSandboxMode,
406
394
  model: this.model, effort: this.effort,
407
- status: this.status, threadId: this.threadId, presentation: this.presentation,
395
+ status: this.status, threadId: this.threadId,
408
396
  aborting: this.aborting, resuming: this.resuming,
409
- canAbort: this.presentation === 'structured' && (this.status !== 'idle' || this.resuming) && !this.aborting,
410
- canCompact: this.presentation === 'structured' && this.status === 'idle' && !this.compacting
397
+ canAbort: (this.status !== 'idle' || this.resuming) && !this.aborting,
398
+ canCompact: this.status === 'idle' && !this.compacting
411
399
  && !this.aborting && !this.resuming && Boolean(this.threadId),
412
400
  compacting: this.compacting,
413
- canSwitchToTerminal: this.presentation === 'structured' && this.status === 'idle'
414
- && !this.aborting && !this.resuming && Boolean(this.threadId),
415
- canSwitchToStructured: this.presentation === 'terminal',
416
401
  pendingPermissionCount: this.pendingPermissions.size, activeSubagentCount, models: this.models };
417
402
  }
418
403
 
@@ -424,15 +409,14 @@ class CodexStructuredSession extends EventEmitter {
424
409
  return item.text || '';
425
410
  }).filter(Boolean).join('\n\n');
426
411
  return { success: true, sessionId: this.id, sessionName: this.name, tool: this.tool.displayName,
427
- historyMode: this.presentation === 'terminal' ? 'terminal' : 'structured', text, updatedAt: Date.now(),
412
+ historyMode: 'structured', text, updatedAt: Date.now(),
428
413
  truncated: false, bytes: Buffer.byteLength(text, 'utf8'), lines: text ? text.split('\n').length : 0 };
429
414
  }
430
415
 
431
416
  getCatchupOutput() {
432
- if (this.presentation === 'terminal') return { source: 'codex-terminal', items: 1, data: this.terminalOutput };
433
417
  return { source: 'codex-structured', items: this.messages.length, data: '' };
434
418
  }
435
- isRunning() { return this.running && (this.presentation !== 'terminal' || Boolean(this.terminalSession)); }
419
+ isRunning() { return this.running; }
436
420
 
437
421
  createItem(item) { return { id: crypto.randomUUID(), createdAt: Date.now(), ...item }; }
438
422
  append(item) {
@@ -502,7 +486,7 @@ class CodexStructuredSession extends EventEmitter {
502
486
  const failure = error instanceof Error ? error : new Error(String(error || 'Codex app-server transport closed'));
503
487
  this.logger.debugInfo?.(`[codex-app-server] transport failed: ${failure.message}`);
504
488
  this.needsThreadResume = Boolean(this.threadId || this.resumeTarget);
505
- if (this.running && this.presentation === 'structured') {
489
+ if (this.running) {
506
490
  const activeTurn = Boolean(this.currentTurnId || this.status === 'running' || this.status === 'waiting_approval');
507
491
  this.clearAbortState(false);
508
492
  this.compacting = false;
@@ -526,7 +510,7 @@ class CodexStructuredSession extends EventEmitter {
526
510
  request.reject(new Error(`Codex app-server exited (${code})`));
527
511
  }
528
512
  this.pendingRequests.clear();
529
- if (this.running && this.presentation === 'structured') {
513
+ if (this.running) {
530
514
  const activeTurn = Boolean(this.currentTurnId || this.status === 'running' || this.status === 'waiting_approval');
531
515
  this.needsThreadResume = Boolean(this.threadId);
532
516
  this.clearAbortState(false);
@@ -1132,7 +1116,6 @@ class CodexStructuredSession extends EventEmitter {
1132
1116
  }
1133
1117
 
1134
1118
  async showStatus() {
1135
- if (this.presentation !== 'structured') return false;
1136
1119
  await this.ensureProcess();
1137
1120
  const accountResult = await this.request('account/read', { refreshToken: false });
1138
1121
  const account = accountResult?.account || null;
@@ -1172,11 +1155,11 @@ class CodexStructuredSession extends EventEmitter {
1172
1155
  }
1173
1156
  const needsConfigDefaults = (settings.permissionMode !== undefined && !this.permissionMode)
1174
1157
  || (settings.sandboxMode !== undefined && !this.sandboxMode);
1175
- if (needsConfigDefaults && this.presentation === 'structured') {
1158
+ if (needsConfigDefaults) {
1176
1159
  await this.ensureProcess();
1177
1160
  await this.refreshConfigDefaults();
1178
1161
  }
1179
- if (this.threadId && this.presentation === 'structured') {
1162
+ if (this.threadId) {
1180
1163
  await this.ensureProcess();
1181
1164
  const params = { threadId: this.threadId };
1182
1165
  if (settings.permissionMode !== undefined) {
@@ -1219,18 +1202,22 @@ class CodexStructuredSession extends EventEmitter {
1219
1202
  return params;
1220
1203
  }
1221
1204
 
1222
- async sendUserMessage(text, attachments = [], skills = []) {
1205
+ async sendUserMessage(text, attachments = [], skills = [], options = {}) {
1223
1206
  const prompt = String(text || '').trim();
1207
+ const agentPrompt = String(options.agentText ?? prompt).trim();
1224
1208
  const images = (Array.isArray(attachments) ? attachments : [])
1225
1209
  .filter(item => item && typeof item.path === 'string' && item.path);
1226
- if ((!prompt && images.length === 0) || this.presentation !== 'structured'
1227
- || this.status !== 'idle' || this.aborting || this.resuming) return false;
1210
+ const displayAttachments = Array.isArray(options.displayAttachments) ? options.displayAttachments : [];
1211
+ if ((!agentPrompt && images.length === 0) || this.status !== 'idle' || this.aborting || this.resuming) return false;
1228
1212
  this.hasUnreadCompletion = false;
1229
1213
  this.promptHistoryCache = null;
1230
1214
  this.append({
1231
1215
  kind: 'user',
1232
- text: prompt || '📷 Image attachment',
1233
- attachments: images.map(item => ({ id: item.id, name: item.name || 'image' })),
1216
+ text: prompt || (displayAttachments.length ? '📎 File attachment' : '📷 Image attachment'),
1217
+ attachments: [
1218
+ ...images.map(item => ({ id: item.id, name: item.name || 'image' })),
1219
+ ...displayAttachments
1220
+ ],
1234
1221
  skills: (Array.isArray(skills) ? skills : []).map(item => ({
1235
1222
  name: String(item?.name || ''), path: String(item?.path || '')
1236
1223
  })).filter(item => item.name && item.path)
@@ -1255,7 +1242,7 @@ class CodexStructuredSession extends EventEmitter {
1255
1242
  }
1256
1243
  const input = [];
1257
1244
  input.push(...await this.resolveSkillInputs(skills));
1258
- if (prompt) input.push({ type: 'text', text: prompt });
1245
+ if (agentPrompt) input.push({ type: 'text', text: agentPrompt });
1259
1246
  for (const image of images) input.push({ type: 'localImage', path: image.path });
1260
1247
  const params = { threadId: this.threadId, input, cwd: this.workingDir, summary: 'auto' };
1261
1248
  if (this.hasModelOverride) params.model = this.model;
@@ -1278,7 +1265,7 @@ class CodexStructuredSession extends EventEmitter {
1278
1265
  }
1279
1266
 
1280
1267
  async compactContext() {
1281
- if (!this.threadId || this.presentation !== 'structured' || this.status !== 'idle'
1268
+ if (!this.threadId || this.status !== 'idle'
1282
1269
  || this.aborting || this.resuming) return false;
1283
1270
  await this.ensureProcess();
1284
1271
  this.compacting = true;
@@ -1295,7 +1282,6 @@ class CodexStructuredSession extends EventEmitter {
1295
1282
  }
1296
1283
 
1297
1284
  write(data) {
1298
- if (this.presentation === 'terminal') return this.terminalSession?.write(data) || false;
1299
1285
  const text = String(data || '').replace(/\r/g, '\n');
1300
1286
  const prompt = text.trim();
1301
1287
  if (prompt) void this.sendUserMessage(prompt).catch(error => {
@@ -1330,7 +1316,7 @@ class CodexStructuredSession extends EventEmitter {
1330
1316
  }
1331
1317
 
1332
1318
  abort(reason = 'Aborted by user') {
1333
- if (this.presentation !== 'structured' || (this.status === 'idle' && !this.resuming)) return false;
1319
+ if (this.status === 'idle' && !this.resuming) return false;
1334
1320
  if (this.aborting) return true;
1335
1321
  if (this.resuming) {
1336
1322
  this.aborting = true;
@@ -1417,7 +1403,7 @@ class CodexStructuredSession extends EventEmitter {
1417
1403
 
1418
1404
  resume(threadId = null) {
1419
1405
  const target = String(threadId || this.threadId || '').trim();
1420
- if (!target || this.presentation !== 'structured' || this.status !== 'idle' || this.aborting) return false;
1406
+ if (!target || this.status !== 'idle' || this.aborting) return false;
1421
1407
  if (this.resumePromise) return target === this.resumeTarget ? this.resumePromise : false;
1422
1408
  this.resuming = true;
1423
1409
  this.resumeTarget = target;
@@ -1521,7 +1507,7 @@ class CodexStructuredSession extends EventEmitter {
1521
1507
 
1522
1508
  async forkFrom(threadId) {
1523
1509
  const sourceThreadId = String(threadId || '').trim();
1524
- if (!sourceThreadId || this.presentation !== 'structured' || this.status !== 'idle'
1510
+ if (!sourceThreadId || this.status !== 'idle'
1525
1511
  || this.aborting || this.resuming) return false;
1526
1512
  await this.ensureProcess();
1527
1513
  const params = { threadId: sourceThreadId, cwd: this.workingDir, ephemeral: false, threadSource: null };
@@ -1542,39 +1528,6 @@ class CodexStructuredSession extends EventEmitter {
1542
1528
  return { threadId: this.threadId };
1543
1529
  }
1544
1530
 
1545
- async switchToTerminal() {
1546
- if (this.presentation !== 'structured' || this.status !== 'idle' || this.aborting
1547
- || this.resuming || !this.threadId) throw new Error('Codex must be idle before switching to Terminal.');
1548
- await this.disconnectProcess();
1549
- this.terminalOutput = '';
1550
- const terminal = new PTYManager(this.tool, this.workingDir, { append() {} }, { silent: true });
1551
- terminal.onData(data => {
1552
- this.terminalOutput = (this.terminalOutput + data).slice(-1024 * 1024);
1553
- this.emit('output', data);
1554
- });
1555
- terminal.onExit(() => { this.terminalSession = null; this.emitEvent({ type: 'state', state: this.getControlState() }); });
1556
- if (!terminal.start(['resume', this.threadId])) throw new Error('Failed to start Codex terminal.');
1557
- this.terminalSession = terminal;
1558
- this.presentation = 'terminal';
1559
- this.emitEvent({ type: 'presentation', presentation: 'terminal', state: this.getControlState() });
1560
- return true;
1561
- }
1562
-
1563
- async switchToStructured() {
1564
- if (this.presentation !== 'terminal') throw new Error('Codex is already in chat mode.');
1565
- await this.ensureProcess();
1566
- const listed = await this.request('thread/list', { limit: 100, archived: false, cwd: this.workingDir, useStateDbOnly: true });
1567
- const current = (listed?.data || []).find(item => item.id === this.threadId);
1568
- if (current?.status?.type === 'active') throw new Error('Codex is still running in Terminal. Wait for it to finish before switching.');
1569
- if (this.terminalSession) {
1570
- this.terminalSession.kill();
1571
- this.terminalSession = null;
1572
- }
1573
- this.presentation = 'structured';
1574
- this.emitEvent({ type: 'presentation', presentation: 'structured', state: this.getControlState() });
1575
- return this.resume();
1576
- }
1577
-
1578
1531
  async disconnectProcess(force = false, reason = null) {
1579
1532
  if (!this.process) return this.processShutdown || undefined;
1580
1533
  const child = this.process;
@@ -1613,8 +1566,6 @@ class CodexStructuredSession extends EventEmitter {
1613
1566
  this.clearAbortState(false);
1614
1567
  this.resuming = false;
1615
1568
  this.resumeTarget = null;
1616
- this.terminalSession?.kill();
1617
- this.terminalSession = null;
1618
1569
  await this.disconnectProcess(true);
1619
1570
  this.emit('exit');
1620
1571
  }
@@ -275,6 +275,39 @@ async function webCommand(options) {
275
275
  }
276
276
  });
277
277
 
278
+ app.post('/api/sessions/:id/attachments/files/chunks', express.raw({ type: () => true, limit: '1mb' }), async (req, res) => {
279
+ try {
280
+ const result = await sessionManager.appendFileChunk(req.params.id, {
281
+ uploadId: req.get('X-Glad-Upload-Id'),
282
+ chunkIndex: req.get('X-Glad-Chunk-Index'),
283
+ chunkTotal: req.get('X-Glad-Chunk-Total'),
284
+ name: req.get('X-Glad-File-Name')
285
+ }, req.body);
286
+ res.json({ success: true, ...result });
287
+ } catch (e) {
288
+ res.status(e.statusCode || 500).json({ error: e.message });
289
+ }
290
+ });
291
+
292
+ app.delete('/api/sessions/:id/attachments/files/uploads/:uploadId', async (req, res) => {
293
+ try {
294
+ const removed = await sessionManager.discardFileUpload(req.params.id, req.params.uploadId);
295
+ res.json({ success: true, removed });
296
+ } catch (e) {
297
+ res.status(e.statusCode || 500).json({ error: e.message });
298
+ }
299
+ });
300
+
301
+ app.delete('/api/sessions/:id/attachments/files/:attachmentId', async (req, res) => {
302
+ try {
303
+ const removed = await sessionManager.discardFileAttachment(req.params.id, req.params.attachmentId);
304
+ if (!removed) return res.status(404).json({ error: 'File attachment not found' });
305
+ res.json({ success: true });
306
+ } catch (e) {
307
+ res.status(e.statusCode || 500).json({ error: e.message });
308
+ }
309
+ });
310
+
278
311
  // API: Delete/Kill session
279
312
  app.delete('/api/sessions/:id', async (req, res) => {
280
313
  await sessionManager.kill(req.params.id);
@@ -335,7 +368,7 @@ async function webCommand(options) {
335
368
  if (session.kind === 'claude-structured') {
336
369
  ws.send(JSON.stringify({ type: 'claude-snapshot', snapshot: sessionManager.getClaudeSnapshot(sessionId) }));
337
370
  }
338
- if (session.kind === 'codex-structured' && session.presentation === 'structured') {
371
+ if (session.kind === 'codex-structured') {
339
372
  ws.send(JSON.stringify({ type: 'codex-snapshot', snapshot: sessionManager.getCodexSnapshot(sessionId) }));
340
373
  }
341
374
 
@@ -347,7 +380,7 @@ async function webCommand(options) {
347
380
  && (isReconnect || (catchup && catchup.source === 'rendered-history'));
348
381
  if (ws.needsTuiRedraw) {
349
382
  ws.send(JSON.stringify({ type: 'reset' }));
350
- } else if (!(session.kind === 'claude-structured' || (session.kind === 'codex-structured' && session.presentation === 'structured')) && catchup && catchup.data) {
383
+ } else if (!['claude-structured', 'codex-structured'].includes(session.kind) && catchup && catchup.data) {
351
384
  sessionManager.logWsCatchupOutput(sessionId, catchup);
352
385
  ws.send(JSON.stringify({ type: 'output', data: catchup.data }));
353
386
  }
@@ -358,8 +391,11 @@ async function webCommand(options) {
358
391
  if (payload.type === 'input') {
359
392
  session.write(payload.data);
360
393
  }
394
+ if (payload.type === 'file-input') {
395
+ sessionManager.sendTerminalFileInput(sessionId, payload.text || '', payload.fileAttachmentIds || []);
396
+ }
361
397
  if (payload.type === 'claude-input') {
362
- sessionManager.sendClaudeInput(sessionId, payload.text || '', payload.attachmentIds || [])
398
+ sessionManager.sendClaudeInput(sessionId, payload.text || '', payload.attachmentIds || [], payload.fileAttachmentIds || [])
363
399
  .catch(error => logger.error(`Claude input error: ${error.message}`));
364
400
  }
365
401
  if (payload.type === 'claude-permission') {
@@ -378,7 +414,7 @@ async function webCommand(options) {
378
414
  sessionManager.abortClaude(sessionId);
379
415
  }
380
416
  if (payload.type === 'codex-input') {
381
- sessionManager.sendCodexInput(sessionId, payload.text || '', payload.attachmentIds || [], payload.skills || [])
417
+ sessionManager.sendCodexInput(sessionId, payload.text || '', payload.attachmentIds || [], payload.skills || [], payload.fileAttachmentIds || [])
382
418
  .catch(error => logger.error(`Codex input error: ${error.message}`));
383
419
  }
384
420
  if (payload.type === 'codex-permission') {
@@ -398,7 +434,7 @@ async function webCommand(options) {
398
434
  }
399
435
  if (payload.type === 'codex-detail-request') {
400
436
  const codex = sessionManager.get(sessionId);
401
- if (codex && codex.kind === 'codex-structured' && codex.presentation === 'structured') {
437
+ if (codex && codex.kind === 'codex-structured') {
402
438
  ws.send(JSON.stringify({
403
439
  type: 'codex-detail-response',
404
440
  requestId: payload.requestId || null,
@@ -104,18 +104,6 @@ function registerProviderRoutes(app, { sessionManager }) {
104
104
  }
105
105
  });
106
106
 
107
- app.post('/api/sessions/:id/codex-presentation', async (req, res) => {
108
- const presentation = req.body?.presentation;
109
- if (!['terminal', 'structured'].includes(presentation)) return res.status(400).json({ error: 'Invalid presentation' });
110
- try {
111
- const success = await sessionManager.switchCodexPresentation(req.params.id, presentation);
112
- if (!success) return res.status(409).json({ error: 'Codex session cannot switch presentation now' });
113
- res.json({ success: true });
114
- } catch (error) {
115
- res.status(409).json({ error: error.message });
116
- }
117
- });
118
-
119
107
  app.post('/api/debug/client-log', (req, res) => {
120
108
  const { sessionId, event, payload } = req.body || {};
121
109
  sessionManager.logClientDebug(sessionId, event, payload);
@@ -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;