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