glad-web 1.0.43 → 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 +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 +24 -73
- package/lib/commands/web.js +43 -5
- package/lib/server/routes/providers.js +0 -12
- package/lib/session/file-attachment-store.js +168 -0
- package/lib/session/session-manager.js +75 -23
- package/lib/web/claude.js +28 -11
- package/lib/web/codex.js +49 -23
- package/lib/web/composer.js +263 -34
- package/lib/web/core.js +22 -6
- package/lib/web/git.js +53 -51
- package/lib/web/gitgraph.js +8 -8
- package/lib/web/index.html +94 -36
- package/lib/web/layout.js +72 -0
- package/lib/web/notifications.js +1 -0
- package/lib/web/session.js +6 -20
- package/lib/web/shell.js +3 -0
- package/lib/web/styles.css +375 -11
- package/lib/web/theme.js +60 -0
- package/lib/web/timed-inputs.js +9 -16
- package/package.json +1 -1
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
|
-
|
|
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,
|
|
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
|
-
...(
|
|
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
|
-
] :
|
|
234
|
+
] : agentPrompt;
|
|
230
235
|
|
|
231
236
|
const sdkMessage = {
|
|
232
237
|
type: 'user',
|
package/lib/codex/image-store.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
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,
|
|
395
|
+
status: this.status, threadId: this.threadId,
|
|
408
396
|
aborting: this.aborting, resuming: this.resuming,
|
|
409
|
-
canAbort:
|
|
410
|
-
canCompact: this.
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1158
|
+
if (needsConfigDefaults) {
|
|
1176
1159
|
await this.ensureProcess();
|
|
1177
1160
|
await this.refreshConfigDefaults();
|
|
1178
1161
|
}
|
|
1179
|
-
if (this.threadId
|
|
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
|
-
|
|
1227
|
-
|
|
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:
|
|
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 (
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
}
|
package/lib/commands/web.js
CHANGED
|
@@ -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'
|
|
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 (!
|
|
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'
|
|
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,
|
|
@@ -475,7 +511,9 @@ async function webCommand(options) {
|
|
|
475
511
|
const webAssets = [
|
|
476
512
|
'gitgraph.js',
|
|
477
513
|
'styles.css',
|
|
514
|
+
'theme.js',
|
|
478
515
|
'core.js',
|
|
516
|
+
'layout.js',
|
|
479
517
|
'notifications.js',
|
|
480
518
|
'claude.js',
|
|
481
519
|
'schedules.js',
|
|
@@ -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;
|