glad-web 1.0.44 → 1.0.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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,22 @@ 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
- };
333
+ this.activeSkill = options.activeSkill && options.activeSkill.name && options.activeSkill.path
334
+ ? { name: String(options.activeSkill.name), path: String(options.activeSkill.path) }
335
+ : null;
336
+ this.extraSkillRoots = (Array.isArray(options.extraSkillRoots) ? options.extraSkillRoots : [])
337
+ .map(value => String(value || '').trim()).filter(Boolean);
345
338
  }
346
339
 
347
340
  toListItem() {
348
341
  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',
342
+ toolKey: this.tool.key, workingDirectory: this.workingDir, mode: 'structured',
350
343
  hasUnreadCompletion: Boolean(this.hasUnreadCompletion), timedInputCount: this.timedInputs.size };
351
344
  }
352
345
 
@@ -404,15 +397,12 @@ class CodexStructuredSession extends EventEmitter {
404
397
  return { permissionMode: this.permissionMode || 'default', sandboxMode: this.sandboxMode || 'default',
405
398
  effectivePermissionMode: this.effectivePermissionMode, effectiveSandboxMode: this.effectiveSandboxMode,
406
399
  model: this.model, effort: this.effort,
407
- status: this.status, threadId: this.threadId, presentation: this.presentation,
400
+ status: this.status, threadId: this.threadId,
408
401
  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
402
+ canAbort: (this.status !== 'idle' || this.resuming) && !this.aborting,
403
+ canCompact: this.status === 'idle' && !this.compacting
411
404
  && !this.aborting && !this.resuming && Boolean(this.threadId),
412
405
  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
406
  pendingPermissionCount: this.pendingPermissions.size, activeSubagentCount, models: this.models };
417
407
  }
418
408
 
@@ -424,15 +414,14 @@ class CodexStructuredSession extends EventEmitter {
424
414
  return item.text || '';
425
415
  }).filter(Boolean).join('\n\n');
426
416
  return { success: true, sessionId: this.id, sessionName: this.name, tool: this.tool.displayName,
427
- historyMode: this.presentation === 'terminal' ? 'terminal' : 'structured', text, updatedAt: Date.now(),
417
+ historyMode: 'structured', text, updatedAt: Date.now(),
428
418
  truncated: false, bytes: Buffer.byteLength(text, 'utf8'), lines: text ? text.split('\n').length : 0 };
429
419
  }
430
420
 
431
421
  getCatchupOutput() {
432
- if (this.presentation === 'terminal') return { source: 'codex-terminal', items: 1, data: this.terminalOutput };
433
422
  return { source: 'codex-structured', items: this.messages.length, data: '' };
434
423
  }
435
- isRunning() { return this.running && (this.presentation !== 'terminal' || Boolean(this.terminalSession)); }
424
+ isRunning() { return this.running; }
436
425
 
437
426
  createItem(item) { return { id: crypto.randomUUID(), createdAt: Date.now(), ...item }; }
438
427
  append(item) {
@@ -502,7 +491,7 @@ class CodexStructuredSession extends EventEmitter {
502
491
  const failure = error instanceof Error ? error : new Error(String(error || 'Codex app-server transport closed'));
503
492
  this.logger.debugInfo?.(`[codex-app-server] transport failed: ${failure.message}`);
504
493
  this.needsThreadResume = Boolean(this.threadId || this.resumeTarget);
505
- if (this.running && this.presentation === 'structured') {
494
+ if (this.running) {
506
495
  const activeTurn = Boolean(this.currentTurnId || this.status === 'running' || this.status === 'waiting_approval');
507
496
  this.clearAbortState(false);
508
497
  this.compacting = false;
@@ -526,7 +515,7 @@ class CodexStructuredSession extends EventEmitter {
526
515
  request.reject(new Error(`Codex app-server exited (${code})`));
527
516
  }
528
517
  this.pendingRequests.clear();
529
- if (this.running && this.presentation === 'structured') {
518
+ if (this.running) {
530
519
  const activeTurn = Boolean(this.currentTurnId || this.status === 'running' || this.status === 'waiting_approval');
531
520
  this.needsThreadResume = Boolean(this.threadId);
532
521
  this.clearAbortState(false);
@@ -556,6 +545,9 @@ class CodexStructuredSession extends EventEmitter {
556
545
  }, { fatalOnTimeout: true });
557
546
  }).then(async () => {
558
547
  this.notify('initialized', {});
548
+ if (this.extraSkillRoots.length) {
549
+ await this.request('skills/extraRoots/set', { extraRoots: this.extraSkillRoots }, { fatalOnTimeout: true });
550
+ }
559
551
  try { await this.refreshConfigDefaults(); } catch (error) { this.logger.debugInfo?.(`[codex-app-server] config/read failed: ${error.message}`); }
560
552
  try { await this.refreshModels(); } catch (error) { this.logger.debugInfo?.(`[codex-app-server] model/list failed: ${error.message}`); }
561
553
  resolve();
@@ -924,10 +916,11 @@ class CodexStructuredSession extends EventEmitter {
924
916
 
925
917
  async listSkills(forceReload = false) {
926
918
  await this.ensureProcess();
927
- const result = await this.request('skills/list', {
919
+ const params = {
928
920
  cwds: [this.workingDir],
929
921
  forceReload: Boolean(forceReload)
930
- });
922
+ };
923
+ const result = await this.request('skills/list', params);
931
924
  const entries = Array.isArray(result?.data) ? result.data : [];
932
925
  const entry = entries.find(item => item?.cwd === this.workingDir) || entries[0] || {};
933
926
  return {
@@ -937,7 +930,10 @@ class CodexStructuredSession extends EventEmitter {
937
930
  }
938
931
 
939
932
  async resolveSkillInputs(skills) {
940
- const requested = (Array.isArray(skills) ? skills : []).slice(0, 8);
933
+ const requested = [
934
+ ...(this.activeSkill ? [this.activeSkill] : []),
935
+ ...(Array.isArray(skills) ? skills : [])
936
+ ].slice(0, 8);
941
937
  if (!requested.length) return [];
942
938
  const available = await this.listSkills(false);
943
939
  const allowed = new Map(available.skills.map(item => [`${item.name}\n${item.path}`, item]));
@@ -1132,7 +1128,6 @@ class CodexStructuredSession extends EventEmitter {
1132
1128
  }
1133
1129
 
1134
1130
  async showStatus() {
1135
- if (this.presentation !== 'structured') return false;
1136
1131
  await this.ensureProcess();
1137
1132
  const accountResult = await this.request('account/read', { refreshToken: false });
1138
1133
  const account = accountResult?.account || null;
@@ -1172,11 +1167,11 @@ class CodexStructuredSession extends EventEmitter {
1172
1167
  }
1173
1168
  const needsConfigDefaults = (settings.permissionMode !== undefined && !this.permissionMode)
1174
1169
  || (settings.sandboxMode !== undefined && !this.sandboxMode);
1175
- if (needsConfigDefaults && this.presentation === 'structured') {
1170
+ if (needsConfigDefaults) {
1176
1171
  await this.ensureProcess();
1177
1172
  await this.refreshConfigDefaults();
1178
1173
  }
1179
- if (this.threadId && this.presentation === 'structured') {
1174
+ if (this.threadId) {
1180
1175
  await this.ensureProcess();
1181
1176
  const params = { threadId: this.threadId };
1182
1177
  if (settings.permissionMode !== undefined) {
@@ -1219,18 +1214,22 @@ class CodexStructuredSession extends EventEmitter {
1219
1214
  return params;
1220
1215
  }
1221
1216
 
1222
- async sendUserMessage(text, attachments = [], skills = []) {
1217
+ async sendUserMessage(text, attachments = [], skills = [], options = {}) {
1223
1218
  const prompt = String(text || '').trim();
1219
+ const agentPrompt = String(options.agentText ?? prompt).trim();
1224
1220
  const images = (Array.isArray(attachments) ? attachments : [])
1225
1221
  .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;
1222
+ const displayAttachments = Array.isArray(options.displayAttachments) ? options.displayAttachments : [];
1223
+ if ((!agentPrompt && images.length === 0) || this.status !== 'idle' || this.aborting || this.resuming) return false;
1228
1224
  this.hasUnreadCompletion = false;
1229
1225
  this.promptHistoryCache = null;
1230
1226
  this.append({
1231
1227
  kind: 'user',
1232
- text: prompt || '📷 Image attachment',
1233
- attachments: images.map(item => ({ id: item.id, name: item.name || 'image' })),
1228
+ text: prompt || (displayAttachments.length ? '📎 File attachment' : '📷 Image attachment'),
1229
+ attachments: [
1230
+ ...images.map(item => ({ id: item.id, name: item.name || 'image' })),
1231
+ ...displayAttachments
1232
+ ],
1234
1233
  skills: (Array.isArray(skills) ? skills : []).map(item => ({
1235
1234
  name: String(item?.name || ''), path: String(item?.path || '')
1236
1235
  })).filter(item => item.name && item.path)
@@ -1255,7 +1254,7 @@ class CodexStructuredSession extends EventEmitter {
1255
1254
  }
1256
1255
  const input = [];
1257
1256
  input.push(...await this.resolveSkillInputs(skills));
1258
- if (prompt) input.push({ type: 'text', text: prompt });
1257
+ if (agentPrompt) input.push({ type: 'text', text: agentPrompt });
1259
1258
  for (const image of images) input.push({ type: 'localImage', path: image.path });
1260
1259
  const params = { threadId: this.threadId, input, cwd: this.workingDir, summary: 'auto' };
1261
1260
  if (this.hasModelOverride) params.model = this.model;
@@ -1278,7 +1277,7 @@ class CodexStructuredSession extends EventEmitter {
1278
1277
  }
1279
1278
 
1280
1279
  async compactContext() {
1281
- if (!this.threadId || this.presentation !== 'structured' || this.status !== 'idle'
1280
+ if (!this.threadId || this.status !== 'idle'
1282
1281
  || this.aborting || this.resuming) return false;
1283
1282
  await this.ensureProcess();
1284
1283
  this.compacting = true;
@@ -1295,7 +1294,6 @@ class CodexStructuredSession extends EventEmitter {
1295
1294
  }
1296
1295
 
1297
1296
  write(data) {
1298
- if (this.presentation === 'terminal') return this.terminalSession?.write(data) || false;
1299
1297
  const text = String(data || '').replace(/\r/g, '\n');
1300
1298
  const prompt = text.trim();
1301
1299
  if (prompt) void this.sendUserMessage(prompt).catch(error => {
@@ -1330,7 +1328,7 @@ class CodexStructuredSession extends EventEmitter {
1330
1328
  }
1331
1329
 
1332
1330
  abort(reason = 'Aborted by user') {
1333
- if (this.presentation !== 'structured' || (this.status === 'idle' && !this.resuming)) return false;
1331
+ if (this.status === 'idle' && !this.resuming) return false;
1334
1332
  if (this.aborting) return true;
1335
1333
  if (this.resuming) {
1336
1334
  this.aborting = true;
@@ -1417,7 +1415,7 @@ class CodexStructuredSession extends EventEmitter {
1417
1415
 
1418
1416
  resume(threadId = null) {
1419
1417
  const target = String(threadId || this.threadId || '').trim();
1420
- if (!target || this.presentation !== 'structured' || this.status !== 'idle' || this.aborting) return false;
1418
+ if (!target || this.status !== 'idle' || this.aborting) return false;
1421
1419
  if (this.resumePromise) return target === this.resumeTarget ? this.resumePromise : false;
1422
1420
  this.resuming = true;
1423
1421
  this.resumeTarget = target;
@@ -1521,7 +1519,7 @@ class CodexStructuredSession extends EventEmitter {
1521
1519
 
1522
1520
  async forkFrom(threadId) {
1523
1521
  const sourceThreadId = String(threadId || '').trim();
1524
- if (!sourceThreadId || this.presentation !== 'structured' || this.status !== 'idle'
1522
+ if (!sourceThreadId || this.status !== 'idle'
1525
1523
  || this.aborting || this.resuming) return false;
1526
1524
  await this.ensureProcess();
1527
1525
  const params = { threadId: sourceThreadId, cwd: this.workingDir, ephemeral: false, threadSource: null };
@@ -1542,39 +1540,6 @@ class CodexStructuredSession extends EventEmitter {
1542
1540
  return { threadId: this.threadId };
1543
1541
  }
1544
1542
 
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
1543
  async disconnectProcess(force = false, reason = null) {
1579
1544
  if (!this.process) return this.processShutdown || undefined;
1580
1545
  const child = this.process;
@@ -1613,8 +1578,6 @@ class CodexStructuredSession extends EventEmitter {
1613
1578
  this.clearAbortState(false);
1614
1579
  this.resuming = false;
1615
1580
  this.resumeTarget = null;
1616
- this.terminalSession?.kill();
1617
- this.terminalSession = null;
1618
1581
  await this.disconnectProcess(true);
1619
1582
  this.emit('exit');
1620
1583
  }
@@ -48,10 +48,14 @@ const registerWorkspaceRoutes = require('../server/routes/workspace');
48
48
  const registerProviderRoutes = require('../server/routes/providers');
49
49
  const registerNotificationRoutes = require('../server/routes/notifications');
50
50
  const registerUsageRoutes = require('../server/routes/usage');
51
+ const registerSkillHubRoutes = require('../server/routes/skillhub');
51
52
  const { UsageService } = require('../usage/usage-service');
52
53
  const { ServerChanSettingsStore } = require('../notifications/serverchan-settings-store');
53
54
  const ServerChanClient = require('../notifications/serverchan-client');
54
55
  const NotificationService = require('../notifications/notification-service');
56
+ const { SkillHubSettingsStore } = require('../skillhub/settings-store');
57
+ const { SkillHubClient } = require('../skillhub/client');
58
+ const { SkillInstaller } = require('../skillhub/skill-installer');
55
59
 
56
60
  async function webCommand(options) {
57
61
  const port = parseInt(options.port) || 3000;
@@ -104,6 +108,10 @@ async function webCommand(options) {
104
108
  logger
105
109
  });
106
110
  const usageService = new UsageService({ logger });
111
+ const skillHubSettings = new SkillHubSettingsStore();
112
+ const skillHubClient = new SkillHubClient({ settingsStore: skillHubSettings });
113
+ const skillInstaller = new SkillInstaller({ client: skillHubClient });
114
+ await skillInstaller.initialize();
107
115
  sessionManager.on('output', ({ sessionId, data }) => {
108
116
  broadcastToSession(sessionId, { type: 'output', data });
109
117
  });
@@ -147,6 +155,12 @@ async function webCommand(options) {
147
155
  notificationService
148
156
  });
149
157
  registerUsageRoutes(app, { usageService, sendJson: sendCompressedJson });
158
+ registerSkillHubRoutes(app, {
159
+ settingsStore: skillHubSettings,
160
+ client: skillHubClient,
161
+ installer: skillInstaller,
162
+ sessionManager
163
+ });
150
164
 
151
165
  // API: List all active sessions
152
166
  app.get('/api/sessions', (req, res) => {
@@ -275,9 +289,43 @@ async function webCommand(options) {
275
289
  }
276
290
  });
277
291
 
292
+ app.post('/api/sessions/:id/attachments/files/chunks', express.raw({ type: () => true, limit: '1mb' }), async (req, res) => {
293
+ try {
294
+ const result = await sessionManager.appendFileChunk(req.params.id, {
295
+ uploadId: req.get('X-Glad-Upload-Id'),
296
+ chunkIndex: req.get('X-Glad-Chunk-Index'),
297
+ chunkTotal: req.get('X-Glad-Chunk-Total'),
298
+ name: req.get('X-Glad-File-Name')
299
+ }, req.body);
300
+ res.json({ success: true, ...result });
301
+ } catch (e) {
302
+ res.status(e.statusCode || 500).json({ error: e.message });
303
+ }
304
+ });
305
+
306
+ app.delete('/api/sessions/:id/attachments/files/uploads/:uploadId', async (req, res) => {
307
+ try {
308
+ const removed = await sessionManager.discardFileUpload(req.params.id, req.params.uploadId);
309
+ res.json({ success: true, removed });
310
+ } catch (e) {
311
+ res.status(e.statusCode || 500).json({ error: e.message });
312
+ }
313
+ });
314
+
315
+ app.delete('/api/sessions/:id/attachments/files/:attachmentId', async (req, res) => {
316
+ try {
317
+ const removed = await sessionManager.discardFileAttachment(req.params.id, req.params.attachmentId);
318
+ if (!removed) return res.status(404).json({ error: 'File attachment not found' });
319
+ res.json({ success: true });
320
+ } catch (e) {
321
+ res.status(e.statusCode || 500).json({ error: e.message });
322
+ }
323
+ });
324
+
278
325
  // API: Delete/Kill session
279
326
  app.delete('/api/sessions/:id', async (req, res) => {
280
- await sessionManager.kill(req.params.id);
327
+ const deleted = await sessionManager.kill(req.params.id);
328
+ if (!deleted) return res.status(404).json({ error: 'Session not found' });
281
329
  res.json({ success: true });
282
330
  });
283
331
 
@@ -335,7 +383,7 @@ async function webCommand(options) {
335
383
  if (session.kind === 'claude-structured') {
336
384
  ws.send(JSON.stringify({ type: 'claude-snapshot', snapshot: sessionManager.getClaudeSnapshot(sessionId) }));
337
385
  }
338
- if (session.kind === 'codex-structured' && session.presentation === 'structured') {
386
+ if (session.kind === 'codex-structured') {
339
387
  ws.send(JSON.stringify({ type: 'codex-snapshot', snapshot: sessionManager.getCodexSnapshot(sessionId) }));
340
388
  }
341
389
 
@@ -347,7 +395,7 @@ async function webCommand(options) {
347
395
  && (isReconnect || (catchup && catchup.source === 'rendered-history'));
348
396
  if (ws.needsTuiRedraw) {
349
397
  ws.send(JSON.stringify({ type: 'reset' }));
350
- } else if (!(session.kind === 'claude-structured' || (session.kind === 'codex-structured' && session.presentation === 'structured')) && catchup && catchup.data) {
398
+ } else if (!['claude-structured', 'codex-structured'].includes(session.kind) && catchup && catchup.data) {
351
399
  sessionManager.logWsCatchupOutput(sessionId, catchup);
352
400
  ws.send(JSON.stringify({ type: 'output', data: catchup.data }));
353
401
  }
@@ -358,8 +406,11 @@ async function webCommand(options) {
358
406
  if (payload.type === 'input') {
359
407
  session.write(payload.data);
360
408
  }
409
+ if (payload.type === 'file-input') {
410
+ sessionManager.sendTerminalFileInput(sessionId, payload.text || '', payload.fileAttachmentIds || []);
411
+ }
361
412
  if (payload.type === 'claude-input') {
362
- sessionManager.sendClaudeInput(sessionId, payload.text || '', payload.attachmentIds || [])
413
+ sessionManager.sendClaudeInput(sessionId, payload.text || '', payload.attachmentIds || [], payload.fileAttachmentIds || [])
363
414
  .catch(error => logger.error(`Claude input error: ${error.message}`));
364
415
  }
365
416
  if (payload.type === 'claude-permission') {
@@ -378,7 +429,7 @@ async function webCommand(options) {
378
429
  sessionManager.abortClaude(sessionId);
379
430
  }
380
431
  if (payload.type === 'codex-input') {
381
- sessionManager.sendCodexInput(sessionId, payload.text || '', payload.attachmentIds || [], payload.skills || [])
432
+ sessionManager.sendCodexInput(sessionId, payload.text || '', payload.attachmentIds || [], payload.skills || [], payload.fileAttachmentIds || [])
382
433
  .catch(error => logger.error(`Codex input error: ${error.message}`));
383
434
  }
384
435
  if (payload.type === 'codex-permission') {
@@ -398,7 +449,7 @@ async function webCommand(options) {
398
449
  }
399
450
  if (payload.type === 'codex-detail-request') {
400
451
  const codex = sessionManager.get(sessionId);
401
- if (codex && codex.kind === 'codex-structured' && codex.presentation === 'structured') {
452
+ if (codex && codex.kind === 'codex-structured') {
402
453
  ws.send(JSON.stringify({
403
454
  type: 'codex-detail-response',
404
455
  requestId: payload.requestId || null,
@@ -473,12 +524,14 @@ async function webCommand(options) {
473
524
  };
474
525
 
475
526
  const webAssets = [
527
+ 'bootstrap.js',
476
528
  'gitgraph.js',
477
529
  'styles.css',
478
530
  'theme.js',
479
531
  'core.js',
480
532
  'layout.js',
481
533
  'notifications.js',
534
+ 'skillhub.js',
482
535
  'claude.js',
483
536
  'schedules.js',
484
537
  'shell.js',
@@ -539,12 +592,14 @@ async function webCommand(options) {
539
592
  console.log(chalk.gray(`Tips: Access from your phone via the Network URL above.\n`));
540
593
  });
541
594
 
542
- process.on('SIGINT', () => {
595
+ const shutdown = () => {
543
596
  schedulerService.stop();
544
597
  notificationService.stop();
545
598
  sessionManager.killAll();
546
- process.exit(0);
547
- });
599
+ process.exit(0);
600
+ };
601
+ process.once('SIGINT', shutdown);
602
+ process.once('SIGTERM', shutdown);
548
603
  }
549
604
 
550
605
  module.exports = webCommand;
@@ -26,6 +26,25 @@ const schema = {
26
26
  clientType: 'wechat'
27
27
  }
28
28
  },
29
+ skillHub: {
30
+ type: 'object',
31
+ properties: {
32
+ baseUrl: { type: 'string', default: '' },
33
+ token: {
34
+ type: 'object',
35
+ properties: {
36
+ ciphertext: { type: 'string', default: '' },
37
+ iv: { type: 'string', default: '' },
38
+ authTag: { type: 'string', default: '' }
39
+ },
40
+ default: { ciphertext: '', iv: '', authTag: '' }
41
+ }
42
+ },
43
+ default: {
44
+ baseUrl: '',
45
+ token: { ciphertext: '', iv: '', authTag: '' }
46
+ }
47
+ },
29
48
  version: {
30
49
  type: 'string',
31
50
  default: '1.0.0'
@@ -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,104 @@
1
+ const { v4: uuidv4 } = require('uuid');
2
+
3
+ function statusCode(error) {
4
+ return Number(error?.statusCode) || 500;
5
+ }
6
+
7
+ function errorBody(error) {
8
+ return {
9
+ error: error?.message || 'SkillHub 操作失败',
10
+ ...(error?.code ? { code: error.code } : {})
11
+ };
12
+ }
13
+
14
+ function registerSkillHubRoutes(app, {
15
+ settingsStore,
16
+ client,
17
+ installer,
18
+ sessionManager
19
+ }) {
20
+ app.get('/api/skillhub/status', (_req, res) => {
21
+ res.json({ available: installer.available === true });
22
+ });
23
+
24
+ app.get('/api/skillhub/settings', (_req, res) => {
25
+ try { res.json(settingsStore.getPublic()); }
26
+ catch (error) { res.status(statusCode(error)).json(errorBody(error)); }
27
+ });
28
+
29
+ app.put('/api/skillhub/settings', async (req, res) => {
30
+ try {
31
+ const resolved = settingsStore.resolve(req.body || {});
32
+ const user = await client.test(resolved);
33
+ const settings = settingsStore.save(resolved);
34
+ res.json({ success: true, settings, user });
35
+ } catch (error) {
36
+ res.status(statusCode(error)).json(errorBody(error));
37
+ }
38
+ });
39
+
40
+ app.delete('/api/skillhub/settings', (_req, res) => {
41
+ try { res.json({ success: true, settings: settingsStore.clear() }); }
42
+ catch (error) { res.status(statusCode(error)).json(errorBody(error)); }
43
+ });
44
+
45
+ app.post('/api/skillhub/settings/test', async (req, res) => {
46
+ try {
47
+ const settings = settingsStore.resolve(req.body || {});
48
+ const user = await client.test(settings);
49
+ res.json({ success: true, user });
50
+ } catch (error) {
51
+ res.status(statusCode(error)).json(errorBody(error));
52
+ }
53
+ });
54
+
55
+ app.get('/api/skillhub/skills', async (_req, res) => {
56
+ try {
57
+ installer.assertAvailable();
58
+ const skills = await client.listSkills();
59
+ res.json({ success: true, skills });
60
+ } catch (error) {
61
+ res.status(statusCode(error)).json(errorBody(error));
62
+ }
63
+ });
64
+
65
+ app.post('/api/skillhub/sessions', async (req, res) => {
66
+ const sessionId = uuidv4();
67
+ let created = false;
68
+ try {
69
+ if (req.body?.toolKey !== 'codex') {
70
+ const error = new Error('SkillHub Session 当前只支持 Codex');
71
+ error.statusCode = 400;
72
+ error.code = 'SKILLHUB_CODEX_ONLY';
73
+ throw error;
74
+ }
75
+ const activeSkill = await installer.prepare(sessionId, req.body?.skill || {});
76
+ const session = sessionManager.create({
77
+ id: sessionId,
78
+ toolKey: 'codex',
79
+ workingDirectory: req.body?.workingDirectory,
80
+ name: activeSkill.name,
81
+ codexOptions: {
82
+ activeSkill,
83
+ extraSkillRoots: [activeSkill.skillsRoot]
84
+ },
85
+ disposeResources: () => installer.cleanupSync(sessionId)
86
+ });
87
+ created = true;
88
+ const defaultPrompt = activeSkill.defaultPrompt
89
+ || '请先用中文介绍这个 Skill 能完成什么、适合哪些任务,以及用户接下来应该如何使用。这一轮只做使用引导。';
90
+ const intro = `$${activeSkill.name}\n\n${defaultPrompt}`;
91
+ const started = await sessionManager.sendCodexInput(session.id, intro, [], [activeSkill], []);
92
+ if (!started) throw new Error('Codex Skill 引导会话启动失败');
93
+ res.status(201).json({ id: session.id, name: session.name });
94
+ } catch (error) {
95
+ if (created) await sessionManager.kill(sessionId).catch(() => {});
96
+ else {
97
+ try { installer.cleanupSync(sessionId); } catch (_) { /* 目录可能尚未创建 */ }
98
+ }
99
+ res.status(statusCode(error)).json(errorBody(error));
100
+ }
101
+ });
102
+ }
103
+
104
+ module.exports = registerSkillHubRoutes;