glad-web 1.0.21 → 1.0.22
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 -2
- package/README.zh-CN.md +2 -2
- package/bin/cli.js +1 -1
- package/lib/ai-tools/registry.js +1 -1
- package/lib/codex/structured-session.js +588 -0
- package/lib/commands/web.js +63 -2
- package/lib/session/session-manager.js +74 -11
- package/lib/web/index.html +378 -13
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ Requirements:
|
|
|
63
63
|
- Node.js `>=18`
|
|
64
64
|
|
|
65
65
|
```bash
|
|
66
|
-
git clone https://github.com/
|
|
66
|
+
git clone https://github.com/Anonymous/Glad.git
|
|
67
67
|
cd glad
|
|
68
68
|
npm install
|
|
69
69
|
node bin/cli.js
|
|
@@ -192,4 +192,4 @@ See [SECURITY.md](./SECURITY.md) for details.
|
|
|
192
192
|
|
|
193
193
|
## License
|
|
194
194
|
|
|
195
|
-
MIT. Glad is maintained by [
|
|
195
|
+
MIT. Glad is maintained by [anonymous](https://github.com/Anonymous/Glad).
|
package/README.zh-CN.md
CHANGED
|
@@ -63,7 +63,7 @@ glad
|
|
|
63
63
|
- Node.js `>=18`
|
|
64
64
|
|
|
65
65
|
```bash
|
|
66
|
-
git clone https://github.com/
|
|
66
|
+
git clone https://github.com/Anonymous/Glad.git
|
|
67
67
|
cd glad
|
|
68
68
|
npm install
|
|
69
69
|
node bin/cli.js
|
|
@@ -192,4 +192,4 @@ Glad 面向受信任的本机或局域网环境使用。
|
|
|
192
192
|
|
|
193
193
|
## 开源协议
|
|
194
194
|
|
|
195
|
-
本项目使用 MIT 协议,由 [
|
|
195
|
+
本项目使用 MIT 协议,由 [anonymous](https://github.com/Anonymous/Glad) 维护。
|
package/bin/cli.js
CHANGED
|
@@ -57,7 +57,7 @@ program.on('--help', () => {
|
|
|
57
57
|
console.log('Supported AI Tools:');
|
|
58
58
|
console.log(' • Claude Code, Aider, GitHub Copilot, Gemini CLI, and more...');
|
|
59
59
|
console.log('');
|
|
60
|
-
console.log('Source: https://gitee.com/
|
|
60
|
+
console.log('Source: https://gitee.com/anonymous/glad');
|
|
61
61
|
console.log('');
|
|
62
62
|
});
|
|
63
63
|
|
package/lib/ai-tools/registry.js
CHANGED
|
@@ -200,7 +200,7 @@ const AI_TOOLS = {
|
|
|
200
200
|
args: [require('path').join(__dirname, 'demo', 'index.js')],
|
|
201
201
|
displayName: 'Demo',
|
|
202
202
|
description: 'Interactive demo for testing (no AI installation required)',
|
|
203
|
-
website: 'https://gitee.com/
|
|
203
|
+
website: 'https://gitee.com/anonymous/glad',
|
|
204
204
|
checkInstalled: async () => true // Always available
|
|
205
205
|
}
|
|
206
206
|
};
|
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
const { EventEmitter } = require('events');
|
|
2
|
+
const { spawn } = require('child_process');
|
|
3
|
+
const readline = require('readline');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const PTYManager = require('../session/pty-manager');
|
|
6
|
+
|
|
7
|
+
const PERMISSION_MODES = new Set(['untrusted', 'on-request', 'never']);
|
|
8
|
+
const FALLBACK_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'ultra'];
|
|
9
|
+
|
|
10
|
+
function normalizePermissionMode(value) {
|
|
11
|
+
const mode = String(value || 'on-request');
|
|
12
|
+
return PERMISSION_MODES.has(mode) ? mode : 'on-request';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function safeJson(value) {
|
|
16
|
+
try { return JSON.stringify(value || {}, null, 2); } catch (_) { return String(value || ''); }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function textFromInputItems(content) {
|
|
20
|
+
return (Array.isArray(content) ? content : [])
|
|
21
|
+
.filter(item => item && item.type === 'text')
|
|
22
|
+
.map(item => item.text || '')
|
|
23
|
+
.join('\n');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toolDetails(raw) {
|
|
27
|
+
if (raw.type === 'commandExecution') {
|
|
28
|
+
return {
|
|
29
|
+
name: 'CodexBash',
|
|
30
|
+
title: 'Command',
|
|
31
|
+
command: String(raw.command || ''),
|
|
32
|
+
cwd: String(raw.cwd || ''),
|
|
33
|
+
input: { command: raw.command || '', cwd: raw.cwd || '' },
|
|
34
|
+
result: typeof raw.aggregatedOutput === 'string' ? raw.aggregatedOutput : '',
|
|
35
|
+
exitCode: raw.exitCode ?? null
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (raw.type === 'fileChange') {
|
|
39
|
+
return {
|
|
40
|
+
name: 'CodexPatch',
|
|
41
|
+
title: 'Apply patch',
|
|
42
|
+
changes: raw.changes || [],
|
|
43
|
+
input: { changes: raw.changes || [] },
|
|
44
|
+
result: ''
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (raw.type === 'mcpToolCall') {
|
|
48
|
+
const server = String(raw.server || 'MCP');
|
|
49
|
+
const tool = String(raw.tool || 'tool');
|
|
50
|
+
return {
|
|
51
|
+
name: 'McpTool',
|
|
52
|
+
title: `${server}.${tool}`,
|
|
53
|
+
server,
|
|
54
|
+
tool,
|
|
55
|
+
input: raw.arguments || {},
|
|
56
|
+
result: raw.error != null ? String(raw.error) : safeJson(raw.result || ''),
|
|
57
|
+
error: raw.error != null ? String(raw.error) : null
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (raw.type === 'collabAgentToolCall') {
|
|
61
|
+
return {
|
|
62
|
+
name: 'Agent',
|
|
63
|
+
title: raw.tool || raw.action || 'Subagent',
|
|
64
|
+
input: raw.arguments || raw.input || raw,
|
|
65
|
+
result: raw.error != null ? String(raw.error) : safeJson(raw.result || ''),
|
|
66
|
+
error: raw.error != null ? String(raw.error) : null,
|
|
67
|
+
subagentId: raw.receiverThreadId || raw.agentId || raw.id || null
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
name: raw.type === 'webSearch' ? 'WebSearch' : (raw.tool || raw.type || 'Tool'),
|
|
72
|
+
title: raw.tool || raw.type || 'Tool',
|
|
73
|
+
input: raw.arguments || raw.input || raw,
|
|
74
|
+
result: raw.error != null ? String(raw.error) : safeJson(raw.result || ''),
|
|
75
|
+
error: raw.error != null ? String(raw.error) : null
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
class CodexStructuredSession extends EventEmitter {
|
|
80
|
+
constructor({ id, tool, workingDir, name, logger, options = {} }) {
|
|
81
|
+
super();
|
|
82
|
+
this.id = id;
|
|
83
|
+
this.tool = tool;
|
|
84
|
+
this.name = name || tool.displayName;
|
|
85
|
+
this.workingDir = workingDir;
|
|
86
|
+
this.logger = logger || console;
|
|
87
|
+
this.kind = 'codex-structured';
|
|
88
|
+
this.startTime = Date.now();
|
|
89
|
+
this.running = true;
|
|
90
|
+
this.status = 'idle';
|
|
91
|
+
this.presentation = 'structured';
|
|
92
|
+
this.messages = [];
|
|
93
|
+
this.pendingPermissions = new Map();
|
|
94
|
+
this.completedPermissions = [];
|
|
95
|
+
this.threadId = options.resume || null;
|
|
96
|
+
this.currentTurnId = null;
|
|
97
|
+
this.currentTurnStartedAt = null;
|
|
98
|
+
this.permissionMode = normalizePermissionMode(options.permissionMode);
|
|
99
|
+
this.model = options.model || null;
|
|
100
|
+
this.effort = options.effort || null;
|
|
101
|
+
this.models = [];
|
|
102
|
+
this.requestId = 0;
|
|
103
|
+
this.pendingRequests = new Map();
|
|
104
|
+
this.process = null;
|
|
105
|
+
this.processReady = null;
|
|
106
|
+
this.terminalSession = null;
|
|
107
|
+
this.terminalOutput = '';
|
|
108
|
+
this.hasUnreadCompletion = false;
|
|
109
|
+
this.inputSeq = 0;
|
|
110
|
+
this.completionReadInputSeq = 0;
|
|
111
|
+
this.timedInputs = new Map();
|
|
112
|
+
this.ptyManager = {
|
|
113
|
+
workingDir,
|
|
114
|
+
isRunning: () => this.isRunning(),
|
|
115
|
+
write: data => this.write(data),
|
|
116
|
+
kill: () => this.kill(),
|
|
117
|
+
resize: (cols, rows) => this.terminalSession?.resize(cols, rows),
|
|
118
|
+
redraw: () => false
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
toListItem() {
|
|
123
|
+
return { id: this.id, name: this.name, tool: this.tool.displayName, startTime: this.startTime,
|
|
124
|
+
toolKey: this.tool.key, workingDirectory: this.workingDir, mode: this.presentation === 'terminal' ? 'terminal' : 'structured',
|
|
125
|
+
hasUnreadCompletion: Boolean(this.hasUnreadCompletion), timedInputCount: this.timedInputs.size };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
snapshot() {
|
|
129
|
+
return { id: this.id, name: this.name, tool: this.tool.displayName, toolKey: this.tool.key,
|
|
130
|
+
status: this.status, state: this.getControlState(), messages: this.messages,
|
|
131
|
+
pendingPermissions: [
|
|
132
|
+
...this.completedPermissions,
|
|
133
|
+
...Array.from(this.pendingPermissions.values()).map(item => item.public)
|
|
134
|
+
] };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
getControlState() {
|
|
138
|
+
return { permissionMode: this.permissionMode, model: this.model, effort: this.effort,
|
|
139
|
+
status: this.status, threadId: this.threadId, presentation: this.presentation,
|
|
140
|
+
canAbort: this.presentation === 'structured' && this.status !== 'idle',
|
|
141
|
+
canSwitchToTerminal: this.presentation === 'structured' && this.status === 'idle' && Boolean(this.threadId),
|
|
142
|
+
canSwitchToStructured: this.presentation === 'terminal',
|
|
143
|
+
pendingPermissionCount: this.pendingPermissions.size, models: this.models };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
getHistory() {
|
|
147
|
+
const text = this.messages.map(item => {
|
|
148
|
+
if (item.kind === 'user') return `User: ${item.text}`;
|
|
149
|
+
if (item.kind === 'assistant') return `Codex: ${item.text}`;
|
|
150
|
+
if (item.kind === 'tool') return `Tool ${item.name}: ${item.summary || ''}`;
|
|
151
|
+
return item.text || '';
|
|
152
|
+
}).filter(Boolean).join('\n\n');
|
|
153
|
+
return { success: true, sessionId: this.id, sessionName: this.name, tool: this.tool.displayName,
|
|
154
|
+
historyMode: this.presentation === 'terminal' ? 'terminal' : 'structured', text, updatedAt: Date.now(),
|
|
155
|
+
truncated: false, bytes: Buffer.byteLength(text, 'utf8'), lines: text ? text.split('\n').length : 0 };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
getCatchupOutput() {
|
|
159
|
+
if (this.presentation === 'terminal') return { source: 'codex-terminal', items: 1, data: this.terminalOutput };
|
|
160
|
+
return { source: 'codex-structured', items: this.messages.length, data: '' };
|
|
161
|
+
}
|
|
162
|
+
isRunning() { return this.running && (this.presentation !== 'terminal' || Boolean(this.terminalSession)); }
|
|
163
|
+
|
|
164
|
+
createItem(item) { return { id: crypto.randomUUID(), createdAt: Date.now(), ...item }; }
|
|
165
|
+
append(item) { const next = this.createItem(item); this.messages.push(next); this.emitEvent({ type: 'message', message: next }); return next; }
|
|
166
|
+
patch(id, patch) {
|
|
167
|
+
const item = this.messages.find(message => message.id === id);
|
|
168
|
+
if (!item) return null;
|
|
169
|
+
Object.assign(item, patch);
|
|
170
|
+
this.emitEvent({ type: 'message-updated', message: item });
|
|
171
|
+
return item;
|
|
172
|
+
}
|
|
173
|
+
emitEvent(event) { this.emit('event', event); }
|
|
174
|
+
setStatus(status) { if (this.status !== status) { this.status = status; this.emitEvent({ type: 'state', state: this.getControlState() }); } }
|
|
175
|
+
recordPermission(request, status, decision) {
|
|
176
|
+
const completed = { ...request, status, decision };
|
|
177
|
+
this.completedPermissions = [...this.completedPermissions.filter(item => item.id !== request.id), completed].slice(-50);
|
|
178
|
+
this.emitEvent({ type: 'permission-updated', request: completed });
|
|
179
|
+
return completed;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async ensureProcess() {
|
|
183
|
+
if (this.processReady) return this.processReady;
|
|
184
|
+
this.processReady = new Promise((resolve, reject) => {
|
|
185
|
+
const child = spawn(this.tool.command, ['app-server', '--listen', 'stdio://'], {
|
|
186
|
+
cwd: this.workingDir, env: { ...process.env }, stdio: ['pipe', 'pipe', 'pipe']
|
|
187
|
+
});
|
|
188
|
+
this.process = child;
|
|
189
|
+
const fail = error => {
|
|
190
|
+
this.processReady = null;
|
|
191
|
+
this.process = null;
|
|
192
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
193
|
+
};
|
|
194
|
+
child.once('error', fail);
|
|
195
|
+
child.once('exit', code => {
|
|
196
|
+
if (this.process === child) {
|
|
197
|
+
this.process = null;
|
|
198
|
+
this.processReady = null;
|
|
199
|
+
for (const request of this.pendingRequests.values()) request.reject(new Error(`Codex app-server exited (${code})`));
|
|
200
|
+
this.pendingRequests.clear();
|
|
201
|
+
if (this.running && this.presentation === 'structured') {
|
|
202
|
+
this.append({ kind: 'event', level: 'error', text: 'Codex app-server exited.' });
|
|
203
|
+
this.setStatus('idle');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
child.stderr.on('data', data => this.logger.debugInfo?.(`[codex-app-server] ${String(data).trim()}`));
|
|
208
|
+
const lines = readline.createInterface({ input: child.stdout });
|
|
209
|
+
lines.on('line', line => this.handleRpcLine(line));
|
|
210
|
+
this.request('initialize', { clientInfo: { name: 'glad-web', title: 'Glad', version: '1.0' }, capabilities: { experimentalApi: true } })
|
|
211
|
+
.then(async () => {
|
|
212
|
+
try { await this.refreshModels(); } catch (error) { this.logger.debugInfo?.(`[codex-app-server] model/list failed: ${error.message}`); }
|
|
213
|
+
resolve();
|
|
214
|
+
}).catch(fail);
|
|
215
|
+
});
|
|
216
|
+
return this.processReady;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
request(method, params) {
|
|
220
|
+
if (!this.process || !this.process.stdin?.writable) return Promise.reject(new Error('Codex app-server is not connected'));
|
|
221
|
+
const id = ++this.requestId;
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
const timer = setTimeout(() => { this.pendingRequests.delete(id); reject(new Error(`${method} timed out`)); }, 30000);
|
|
224
|
+
this.pendingRequests.set(id, { resolve, reject, timer });
|
|
225
|
+
this.process.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
notify(method, params) {
|
|
230
|
+
if (!this.process || !this.process.stdin?.writable) return false;
|
|
231
|
+
this.process.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
respond(id, result) {
|
|
236
|
+
if (!this.process || !this.process.stdin?.writable) return false;
|
|
237
|
+
this.process.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
handleRpcLine(line) {
|
|
242
|
+
let message;
|
|
243
|
+
try { message = JSON.parse(line); } catch (_) { return; }
|
|
244
|
+
if (message.id !== undefined && !message.method) {
|
|
245
|
+
const request = this.pendingRequests.get(message.id);
|
|
246
|
+
if (!request) return;
|
|
247
|
+
this.pendingRequests.delete(message.id);
|
|
248
|
+
clearTimeout(request.timer);
|
|
249
|
+
if (message.error) request.reject(new Error(message.error.message || 'Codex RPC error'));
|
|
250
|
+
else request.resolve(message.result);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (message.id !== undefined && message.method) {
|
|
254
|
+
this.handleServerRequest(message);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (message.method) this.handleNotification(message.method, message.params || {});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
handleServerRequest(message) {
|
|
261
|
+
const params = message.params || {};
|
|
262
|
+
if (message.method === 'mcpServer/elicitation/request') {
|
|
263
|
+
const toolMatch = typeof params.message === 'string' ? params.message.match(/tool "([^"]+)"/i) : null;
|
|
264
|
+
const id = String(params.callId || `${params.serverName || 'mcp'}:${message.id}`);
|
|
265
|
+
const toolName = toolMatch?.[1] || params.serverName || 'MCP tool';
|
|
266
|
+
const publicRequest = { id, status: 'pending', title: toolName, toolName,
|
|
267
|
+
input: params._meta?.tool_params || {}, reason: params.message || '', canAllowTool: true };
|
|
268
|
+
this.pendingPermissions.set(id, { rpcId: message.id, public: publicRequest, method: message.method, params });
|
|
269
|
+
this.setStatus('waiting_approval');
|
|
270
|
+
this.emitEvent({ type: 'permission-request', request: publicRequest });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (message.method === 'item/tool/requestUserInput') {
|
|
274
|
+
this.append({ kind: 'event', level: 'warning', text: 'Codex requested additional input in Terminal-compatible form. The request was skipped.' });
|
|
275
|
+
this.respond(message.id, { answers: {} });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (['item/commandExecution/requestApproval', 'item/fileChange/requestApproval', 'item/permissions/requestApproval'].includes(message.method)) {
|
|
279
|
+
const id = String(params.itemId || params.callId || params.approvalId || message.id);
|
|
280
|
+
const name = message.method.includes('fileChange') ? 'File change' : message.method.includes('permissions') ? 'Permission request' : 'Command execution';
|
|
281
|
+
const publicRequest = { id, status: 'pending', title: name, toolName: name,
|
|
282
|
+
input: params, reason: params.reason || '', canAllowTool: false };
|
|
283
|
+
this.pendingPermissions.set(id, { rpcId: message.id, public: publicRequest, method: message.method });
|
|
284
|
+
this.setStatus('waiting_approval');
|
|
285
|
+
this.emitEvent({ type: 'permission-request', request: publicRequest });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
this.respond(message.id, null);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
handleNotification(method, params) {
|
|
292
|
+
if (method === 'turn/started') {
|
|
293
|
+
this.currentTurnId = params.turn?.id || params.turnId || this.currentTurnId;
|
|
294
|
+
this.currentTurnStartedAt = Date.now();
|
|
295
|
+
this.append({ kind: 'turn-start', turnId: this.currentTurnId });
|
|
296
|
+
this.setStatus('running');
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (method === 'turn/completed') {
|
|
300
|
+
const completedTurnId = params.turn?.id || params.turnId || this.currentTurnId;
|
|
301
|
+
const turnStatus = params.turn?.status === 'failed' || params.turn?.error ? 'failed'
|
|
302
|
+
: params.turn?.status === 'interrupted' ? 'cancelled' : 'completed';
|
|
303
|
+
this.append({ kind: 'turn-end', turnId: completedTurnId, status: turnStatus,
|
|
304
|
+
durationMs: this.currentTurnStartedAt ? Date.now() - this.currentTurnStartedAt : null });
|
|
305
|
+
for (const pending of this.pendingPermissions.values()) {
|
|
306
|
+
this.recordPermission(pending.public, 'denied', 'abort');
|
|
307
|
+
}
|
|
308
|
+
this.currentTurnId = null;
|
|
309
|
+
this.currentTurnStartedAt = null;
|
|
310
|
+
this.pendingPermissions.clear();
|
|
311
|
+
if (params.turn?.status === 'failed' || params.turn?.error) {
|
|
312
|
+
this.append({ kind: 'event', level: 'error', text: params.turn?.error?.message || 'Codex turn failed.' });
|
|
313
|
+
}
|
|
314
|
+
this.setStatus('idle');
|
|
315
|
+
this.hasUnreadCompletion = true;
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (method === 'thread/started' || method === 'thread/resumed') {
|
|
319
|
+
const threadId = params.thread?.id || params.threadId;
|
|
320
|
+
if (threadId) { this.threadId = threadId; this.emitEvent({ type: 'state', state: this.getControlState() }); }
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (method === 'thread/status/changed') {
|
|
324
|
+
const status = params.status?.type || params.status;
|
|
325
|
+
if (status === 'idle') this.setStatus('idle');
|
|
326
|
+
if (status === 'active') this.setStatus('running');
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (method === 'thread/settings/updated') {
|
|
330
|
+
const settings = params.threadSettings || {};
|
|
331
|
+
this.model = settings.model || this.model;
|
|
332
|
+
this.effort = settings.effort || this.effort;
|
|
333
|
+
this.permissionMode = settings.approvalPolicy || this.permissionMode;
|
|
334
|
+
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (method === 'error') {
|
|
338
|
+
this.append({ kind: 'event', level: 'error', text: params.error?.message || 'Codex reported an error.' });
|
|
339
|
+
if (!params.willRetry) this.setStatus('idle');
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (method === 'warning' || method === 'guardianWarning') {
|
|
343
|
+
this.append({ kind: 'event', level: 'warning', text: params.message || params.warning || 'Codex warning.' });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (method === 'item/commandExecution/outputDelta' || method === 'item/fileChange/outputDelta') {
|
|
347
|
+
const target = this.messages.find(item => item.providerId === String(params.itemId || '') && item.kind === 'tool');
|
|
348
|
+
if (target) this.patch(target.id, { result: String(target.result || '') + String(params.delta || '') });
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (method === 'item/plan/delta') {
|
|
352
|
+
const providerId = String(params.itemId || '');
|
|
353
|
+
const target = this.messages.find(item => item.providerId === providerId && item.kind === 'reasoning');
|
|
354
|
+
if (target) this.patch(target.id, { text: String(target.text || '') + String(params.delta || '') });
|
|
355
|
+
else this.append({ kind: 'reasoning', providerId, text: String(params.delta || ''), streaming: true });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (method.includes('agentMessage/delta') || method.includes('reasoning/textDelta') || method.includes('reasoning/summaryTextDelta')) {
|
|
359
|
+
const kind = method.includes('agentMessage') ? 'assistant' : 'reasoning';
|
|
360
|
+
const itemId = String(params.itemId || params.id || '');
|
|
361
|
+
const target = this.messages.find(item => item.providerId === itemId && item.kind === kind);
|
|
362
|
+
const delta = String(params.delta || '');
|
|
363
|
+
if (target) this.patch(target.id, { text: (target.text || '') + delta });
|
|
364
|
+
else this.append({ kind, providerId: itemId, text: delta, streaming: true });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (method.startsWith('item/')) this.applyProviderItem(params.item || params);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
applyProviderItem(raw) {
|
|
371
|
+
if (!raw || typeof raw !== 'object') return;
|
|
372
|
+
const providerId = String(raw.id || '');
|
|
373
|
+
const existing = providerId && this.messages.find(item => item.providerId === providerId);
|
|
374
|
+
const kind = raw.type === 'userMessage' ? 'user' : raw.type === 'agentMessage' ? 'assistant' : ['reasoning', 'plan'].includes(raw.type) ? 'reasoning'
|
|
375
|
+
: ['commandExecution', 'fileChange', 'mcpToolCall', 'dynamicToolCall', 'webSearch', 'collabAgentToolCall'].includes(raw.type) ? 'tool' : null;
|
|
376
|
+
if (!kind) return;
|
|
377
|
+
const text = kind === 'user' ? textFromInputItems(raw.content) : kind === 'assistant' ? String(raw.text || '')
|
|
378
|
+
: kind === 'reasoning' ? (raw.text || (Array.isArray(raw.summary) ? raw.summary.join('\n') : Array.isArray(raw.content) ? raw.content.join('\n') : '')) : '';
|
|
379
|
+
const patch = kind === 'tool' ? { ...toolDetails(raw), turnId: raw.turnId || this.currentTurnId,
|
|
380
|
+
toolStatus: raw.status || 'running' } : { text, turnId: raw.turnId || this.currentTurnId, streaming: false };
|
|
381
|
+
if (existing) {
|
|
382
|
+
this.patch(existing.id, patch);
|
|
383
|
+
} else if (kind === 'user') {
|
|
384
|
+
const local = [...this.messages].reverse().find(item => item.kind === 'user' && !item.providerId && item.text === text);
|
|
385
|
+
if (local) this.patch(local.id, { providerId, ...patch });
|
|
386
|
+
else this.append({ kind, providerId, ...patch });
|
|
387
|
+
} else {
|
|
388
|
+
this.append({ kind, providerId, ...patch });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async refreshModels() {
|
|
393
|
+
const models = [];
|
|
394
|
+
let cursor = null;
|
|
395
|
+
do {
|
|
396
|
+
const result = await this.request('model/list', { cursor, limit: 100, includeHidden: false });
|
|
397
|
+
for (const item of result?.data || []) models.push({ id: item.id || item.model, label: item.displayName || item.model || item.id,
|
|
398
|
+
efforts: (item.supportedReasoningEfforts || []).map(value => value.reasoningEffort), defaultEffort: item.defaultReasoningEffort || null });
|
|
399
|
+
cursor = result?.nextCursor || null;
|
|
400
|
+
} while (cursor);
|
|
401
|
+
this.models = models;
|
|
402
|
+
if (!this.model && models[0]) this.model = models[0].id;
|
|
403
|
+
if (!this.effort) this.effort = models.find(item => item.id === this.model)?.defaultEffort || 'medium';
|
|
404
|
+
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
405
|
+
return models;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async listResumeThreads() {
|
|
409
|
+
await this.ensureProcess();
|
|
410
|
+
const result = await this.request('thread/list', {
|
|
411
|
+
cursor: null,
|
|
412
|
+
limit: 40,
|
|
413
|
+
sortKey: 'updated_at',
|
|
414
|
+
sortDirection: 'desc',
|
|
415
|
+
archived: false,
|
|
416
|
+
cwd: this.workingDir
|
|
417
|
+
});
|
|
418
|
+
return (result?.data || []).filter(item => !item.parentThreadId).map(item => ({
|
|
419
|
+
id: item.id,
|
|
420
|
+
sessionId: item.sessionId || item.id,
|
|
421
|
+
preview: item.preview || item.name || '',
|
|
422
|
+
updatedAt: Number(item.updatedAt || item.createdAt || 0) * 1000,
|
|
423
|
+
cwd: item.cwd || '',
|
|
424
|
+
current: item.id === this.threadId
|
|
425
|
+
}));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async updateSettings(settings = {}) {
|
|
429
|
+
if (settings.permissionMode !== undefined) this.permissionMode = normalizePermissionMode(settings.permissionMode);
|
|
430
|
+
if (settings.model !== undefined) this.model = settings.model || null;
|
|
431
|
+
if (settings.effort !== undefined) this.effort = settings.effort || null;
|
|
432
|
+
if (this.threadId && this.presentation === 'structured') {
|
|
433
|
+
await this.ensureProcess();
|
|
434
|
+
await this.request('thread/settings/update', { threadId: this.threadId, approvalPolicy: this.permissionMode, model: this.model, effort: this.effort });
|
|
435
|
+
}
|
|
436
|
+
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
437
|
+
return this.getControlState();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async sendUserMessage(text) {
|
|
441
|
+
const prompt = String(text || '').trim();
|
|
442
|
+
if (!prompt || this.presentation !== 'structured' || this.status !== 'idle') return false;
|
|
443
|
+
this.hasUnreadCompletion = false;
|
|
444
|
+
this.append({ kind: 'user', text: prompt });
|
|
445
|
+
await this.ensureProcess();
|
|
446
|
+
if (!this.threadId) {
|
|
447
|
+
const started = await this.request('thread/start', { model: this.model, cwd: this.workingDir, approvalPolicy: this.permissionMode, sandbox: 'workspace-write' });
|
|
448
|
+
this.threadId = started.thread?.id;
|
|
449
|
+
this.model = started.model || this.model;
|
|
450
|
+
this.effort = started.reasoningEffort || this.effort;
|
|
451
|
+
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
452
|
+
}
|
|
453
|
+
this.setStatus('running');
|
|
454
|
+
const started = await this.request('turn/start', { threadId: this.threadId, input: [{ type: 'text', text: prompt }], cwd: this.workingDir,
|
|
455
|
+
approvalPolicy: this.permissionMode,
|
|
456
|
+
sandboxPolicy: { type: 'workspaceWrite', writableRoots: [this.workingDir], networkAccess: false, excludeTmpdirEnvVar: false, excludeSlashTmp: false },
|
|
457
|
+
model: this.model, effort: this.effort, summary: 'auto' });
|
|
458
|
+
this.currentTurnId = started?.turn?.id || started?.turnId || this.currentTurnId;
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
write(data) {
|
|
463
|
+
if (this.presentation === 'terminal') return this.terminalSession?.write(data) || false;
|
|
464
|
+
const text = String(data || '').replace(/\r/g, '\n');
|
|
465
|
+
const prompt = text.trim();
|
|
466
|
+
if (prompt) void this.sendUserMessage(prompt).catch(error => {
|
|
467
|
+
this.currentTurnId = null;
|
|
468
|
+
this.setStatus('idle');
|
|
469
|
+
this.append({ kind: 'event', level: 'error', text: error.message });
|
|
470
|
+
});
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
respondPermission(id, decision) {
|
|
475
|
+
const pending = this.pendingPermissions.get(id);
|
|
476
|
+
if (!pending) return false;
|
|
477
|
+
const normalized = ['approved', 'approved_for_session', 'denied', 'abort'].includes(decision)
|
|
478
|
+
? decision : (decision ? 'approved' : 'denied');
|
|
479
|
+
this.pendingPermissions.delete(id);
|
|
480
|
+
if (pending.method === 'mcpServer/elicitation/request') {
|
|
481
|
+
const action = normalized === 'approved' || normalized === 'approved_for_session' ? 'accept'
|
|
482
|
+
: normalized === 'abort' ? 'cancel' : 'decline';
|
|
483
|
+
this.respond(pending.rpcId, { action, content: action === 'accept' && pending.params?.mode === 'form' ? {} : null, _meta: null });
|
|
484
|
+
} else if (pending.method === 'item/permissions/requestApproval') {
|
|
485
|
+
const approved = normalized === 'approved' || normalized === 'approved_for_session';
|
|
486
|
+
this.respond(pending.rpcId, { permissions: approved ? (pending.public.input.permissions || {}) : {},
|
|
487
|
+
scope: normalized === 'approved_for_session' ? 'session' : 'turn' });
|
|
488
|
+
} else {
|
|
489
|
+
const wireDecision = normalized === 'approved' ? 'accept' : normalized === 'approved_for_session' ? 'acceptForSession'
|
|
490
|
+
: normalized === 'abort' ? 'cancel' : 'decline';
|
|
491
|
+
this.respond(pending.rpcId, { decision: wireDecision });
|
|
492
|
+
}
|
|
493
|
+
const status = normalized === 'approved' || normalized === 'approved_for_session' ? 'approved' : 'denied';
|
|
494
|
+
this.recordPermission(pending.public, status, normalized);
|
|
495
|
+
this.setStatus(this.pendingPermissions.size ? 'waiting_approval' : 'running');
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
abort(reason = 'Aborted by user') {
|
|
500
|
+
if (this.presentation !== 'structured' || this.status === 'idle') return false;
|
|
501
|
+
for (const pending of this.pendingPermissions.values()) {
|
|
502
|
+
const response = pending.method === 'item/permissions/requestApproval'
|
|
503
|
+
? { permissions: {}, scope: 'turn' }
|
|
504
|
+
: pending.method === 'mcpServer/elicitation/request'
|
|
505
|
+
? { action: 'cancel', content: null, _meta: null }
|
|
506
|
+
: { decision: 'cancel' };
|
|
507
|
+
this.respond(pending.rpcId, response);
|
|
508
|
+
this.recordPermission(pending.public, 'denied', 'abort');
|
|
509
|
+
}
|
|
510
|
+
this.pendingPermissions.clear();
|
|
511
|
+
if (this.threadId && this.currentTurnId) {
|
|
512
|
+
this.request('turn/interrupt', { threadId: this.threadId, turnId: this.currentTurnId }).catch(error => {
|
|
513
|
+
this.logger.debugInfo?.(`[codex-app-server] turn/interrupt failed: ${error.message}`);
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
this.append({ kind: 'event', level: 'info', text: reason });
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async resume(threadId = null) {
|
|
521
|
+
const target = String(threadId || this.threadId || '').trim();
|
|
522
|
+
if (!target || this.presentation !== 'structured' || this.status !== 'idle') return false;
|
|
523
|
+
await this.ensureProcess();
|
|
524
|
+
const result = await this.request('thread/resume', { threadId: target, model: this.model, cwd: this.workingDir, approvalPolicy: this.permissionMode, sandbox: 'workspace-write' });
|
|
525
|
+
this.threadId = result.thread?.id || target;
|
|
526
|
+
this.model = result.model || this.model;
|
|
527
|
+
const history = await this.request('thread/read', { threadId: this.threadId, includeTurns: true });
|
|
528
|
+
this.messages = [];
|
|
529
|
+
this.completedPermissions = [];
|
|
530
|
+
for (const turn of history?.thread?.turns || []) {
|
|
531
|
+
this.append({ kind: 'turn-start', turnId: turn.id });
|
|
532
|
+
for (const item of turn.items || []) this.applyProviderItem({ ...item, turnId: turn.id });
|
|
533
|
+
const status = turn.status === 'failed' ? 'failed' : turn.status === 'interrupted' ? 'cancelled' : 'completed';
|
|
534
|
+
this.append({ kind: 'turn-end', turnId: turn.id, status });
|
|
535
|
+
}
|
|
536
|
+
this.append({ kind: 'event', level: 'info', text: `Resumed Codex thread ${this.threadId}` });
|
|
537
|
+
this.emitEvent({ type: 'history-reset', messages: this.messages });
|
|
538
|
+
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async switchToTerminal() {
|
|
543
|
+
if (this.presentation !== 'structured' || this.status !== 'idle' || !this.threadId) throw new Error('Codex must be idle before switching to Terminal.');
|
|
544
|
+
await this.disconnectProcess();
|
|
545
|
+
this.terminalOutput = '';
|
|
546
|
+
const terminal = new PTYManager(this.tool, this.workingDir, { append() {} }, { silent: true });
|
|
547
|
+
terminal.onData(data => {
|
|
548
|
+
this.terminalOutput = (this.terminalOutput + data).slice(-1024 * 1024);
|
|
549
|
+
this.emit('output', data);
|
|
550
|
+
});
|
|
551
|
+
terminal.onExit(() => { this.terminalSession = null; this.emitEvent({ type: 'state', state: this.getControlState() }); });
|
|
552
|
+
if (!terminal.start(['resume', this.threadId])) throw new Error('Failed to start Codex terminal.');
|
|
553
|
+
this.terminalSession = terminal;
|
|
554
|
+
this.presentation = 'terminal';
|
|
555
|
+
this.emitEvent({ type: 'presentation', presentation: 'terminal', state: this.getControlState() });
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async switchToStructured() {
|
|
560
|
+
if (this.presentation !== 'terminal') throw new Error('Codex is already in chat mode.');
|
|
561
|
+
await this.ensureProcess();
|
|
562
|
+
const listed = await this.request('thread/list', { limit: 100, archived: false, cwd: this.workingDir, useStateDbOnly: true });
|
|
563
|
+
const current = (listed?.data || []).find(item => item.id === this.threadId);
|
|
564
|
+
if (current?.status?.type === 'active') throw new Error('Codex is still running in Terminal. Wait for it to finish before switching.');
|
|
565
|
+
if (this.terminalSession) {
|
|
566
|
+
this.terminalSession.kill();
|
|
567
|
+
this.terminalSession = null;
|
|
568
|
+
}
|
|
569
|
+
this.presentation = 'structured';
|
|
570
|
+
this.emitEvent({ type: 'presentation', presentation: 'structured', state: this.getControlState() });
|
|
571
|
+
return this.resume();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async disconnectProcess() {
|
|
575
|
+
if (!this.process) return;
|
|
576
|
+
const child = this.process;
|
|
577
|
+
this.process = null;
|
|
578
|
+
this.processReady = null;
|
|
579
|
+
for (const request of this.pendingRequests.values()) { clearTimeout(request.timer); request.reject(new Error('Codex app-server disconnected')); }
|
|
580
|
+
this.pendingRequests.clear();
|
|
581
|
+
child.kill();
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
markCompletionRead() { this.hasUnreadCompletion = false; this.completionReadInputSeq = this.inputSeq; }
|
|
585
|
+
kill() { this.running = false; this.terminalSession?.kill(); this.terminalSession = null; void this.disconnectProcess(); this.emit('exit'); }
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
module.exports = CodexStructuredSession;
|