glad-web 1.0.44 → 1.0.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/lib/claude/structured-session.js +10 -5
- package/lib/codex/image-store.js +1 -2
- package/lib/codex/structured-session.js +39 -76
- package/lib/commands/web.js +64 -9
- package/lib/config/manager.js +19 -0
- package/lib/server/routes/providers.js +0 -12
- package/lib/server/routes/skillhub.js +104 -0
- package/lib/session/file-attachment-store.js +168 -0
- package/lib/session/session-manager.js +143 -60
- package/lib/skillhub/client.js +121 -0
- package/lib/skillhub/settings-store.js +168 -0
- package/lib/skillhub/skill-installer.js +320 -0
- package/lib/web/bootstrap.js +34 -0
- package/lib/web/claude.js +44 -16
- package/lib/web/codex.js +51 -22
- package/lib/web/composer.js +293 -28
- package/lib/web/core.js +56 -38
- package/lib/web/git.js +53 -51
- package/lib/web/gitgraph.js +8 -8
- package/lib/web/index.html +61 -26
- package/lib/web/layout.js +4 -7
- package/lib/web/notifications.js +1 -0
- package/lib/web/session.js +7 -21
- package/lib/web/shell.js +17 -2
- package/lib/web/skillhub.js +197 -0
- package/lib/web/styles.css +138 -16
- package/lib/web/timed-inputs.js +6 -6
- package/package.json +6 -3
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,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.
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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:
|
|
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,
|
|
400
|
+
status: this.status, threadId: this.threadId,
|
|
408
401
|
aborting: this.aborting, resuming: this.resuming,
|
|
409
|
-
canAbort:
|
|
410
|
-
canCompact: this.
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
1170
|
+
if (needsConfigDefaults) {
|
|
1176
1171
|
await this.ensureProcess();
|
|
1177
1172
|
await this.refreshConfigDefaults();
|
|
1178
1173
|
}
|
|
1179
|
-
if (this.threadId
|
|
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
|
-
|
|
1227
|
-
|
|
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:
|
|
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 (
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
}
|
package/lib/commands/web.js
CHANGED
|
@@ -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'
|
|
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 (!
|
|
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'
|
|
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
|
-
|
|
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;
|
package/lib/config/manager.js
CHANGED
|
@@ -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;
|