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 CHANGED
@@ -63,7 +63,7 @@ Requirements:
63
63
  - Node.js `>=18`
64
64
 
65
65
  ```bash
66
- git clone https://github.com/Next2012/Glad.git
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 [next2012](https://github.com/Next2012/Glad).
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/Next2012/Glad.git
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 协议,由 [next2012](https://github.com/Next2012/Glad) 维护。
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/next2012/glad');
60
+ console.log('Source: https://gitee.com/anonymous/glad');
61
61
  console.log('');
62
62
  });
63
63
 
@@ -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/next2012/glad',
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;