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.
@@ -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;