glad-web 1.0.46 → 2.0.1

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.
Files changed (74) hide show
  1. package/README.md +4 -192
  2. package/THIRD_PARTY_NOTICES.md +27 -0
  3. package/bin/glad.cjs +56 -0
  4. package/package.json +19 -61
  5. package/README.zh-CN.md +0 -198
  6. package/assets/logo.svg +0 -43
  7. package/bin/cli.js +0 -65
  8. package/lib/ai-tools/demo/enhanced-demo.js +0 -625
  9. package/lib/ai-tools/demo/index.js +0 -24
  10. package/lib/ai-tools/demo/responses.js +0 -88
  11. package/lib/ai-tools/detector.js +0 -76
  12. package/lib/ai-tools/registry.js +0 -300
  13. package/lib/claude/cli-usage.js +0 -95
  14. package/lib/claude/config.js +0 -82
  15. package/lib/claude/structured-session.js +0 -884
  16. package/lib/claude/transcript-repository.js +0 -216
  17. package/lib/codex/image-store.js +0 -174
  18. package/lib/codex/structured-session.js +0 -1590
  19. package/lib/commands/config.js +0 -78
  20. package/lib/commands/tools.js +0 -128
  21. package/lib/commands/web.js +0 -605
  22. package/lib/config/constants.js +0 -17
  23. package/lib/config/manager.js +0 -108
  24. package/lib/git/service.js +0 -83
  25. package/lib/notifications/message-formatter.js +0 -94
  26. package/lib/notifications/notification-service.js +0 -143
  27. package/lib/notifications/serverchan-client.js +0 -58
  28. package/lib/notifications/serverchan-settings-store.js +0 -115
  29. package/lib/schedule/job-runner.js +0 -162
  30. package/lib/schedule/job-store.js +0 -167
  31. package/lib/schedule/key-sequences.js +0 -49
  32. package/lib/schedule/scheduler-service.js +0 -39
  33. package/lib/server/routes/notifications.js +0 -52
  34. package/lib/server/routes/providers.js +0 -114
  35. package/lib/server/routes/schedules.js +0 -54
  36. package/lib/server/routes/skillhub.js +0 -104
  37. package/lib/server/routes/usage.js +0 -23
  38. package/lib/server/routes/workspace.js +0 -77
  39. package/lib/session/buffer.js +0 -102
  40. package/lib/session/file-attachment-store.js +0 -168
  41. package/lib/session/pty-manager.js +0 -255
  42. package/lib/session/rendered-history.js +0 -225
  43. package/lib/session/session-manager.js +0 -1032
  44. package/lib/session/text-history.js +0 -274
  45. package/lib/skillhub/client.js +0 -121
  46. package/lib/skillhub/settings-store.js +0 -168
  47. package/lib/skillhub/skill-installer.js +0 -320
  48. package/lib/usage/ccusage-runner.js +0 -128
  49. package/lib/usage/source-catalog.js +0 -26
  50. package/lib/usage/usage-service.js +0 -226
  51. package/lib/utils/logger.js +0 -74
  52. package/lib/utils/pid.js +0 -67
  53. package/lib/utils/validation.js +0 -53
  54. package/lib/web/bootstrap.js +0 -34
  55. package/lib/web/claude.js +0 -1150
  56. package/lib/web/codex.js +0 -1045
  57. package/lib/web/composer.js +0 -493
  58. package/lib/web/core.js +0 -385
  59. package/lib/web/git.js +0 -535
  60. package/lib/web/gitgraph.js +0 -293
  61. package/lib/web/index.html +0 -547
  62. package/lib/web/layout.js +0 -69
  63. package/lib/web/notifications.js +0 -164
  64. package/lib/web/schedules.js +0 -245
  65. package/lib/web/session.js +0 -361
  66. package/lib/web/shell.js +0 -74
  67. package/lib/web/skillhub.js +0 -197
  68. package/lib/web/styles.css +0 -932
  69. package/lib/web/terminal-scroll.js +0 -81
  70. package/lib/web/theme.js +0 -60
  71. package/lib/web/timed-inputs.js +0 -216
  72. package/lib/web/usage.js +0 -323
  73. package/lib/workspace/service.js +0 -77
  74. package/scripts/check-syntax.js +0 -26
@@ -1,1590 +0,0 @@
1
- const { EventEmitter } = require('events');
2
- const { spawn } = require('child_process');
3
- const net = require('net');
4
- const crypto = require('crypto');
5
- const WebSocket = require('ws');
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
- const DEFAULT_ABORT_GRACE_MS = 5000;
11
- const PROCESS_SHUTDOWN_TIMEOUT_MS = 2000;
12
- const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
13
- const RESUME_REQUEST_TIMEOUT_MS = 60000;
14
- const RESUME_HISTORY_TURN_LIMIT = 50;
15
- const APP_SERVER_CONNECT_TIMEOUT_MS = 10000;
16
-
17
- function turnKey(threadId, turnId) {
18
- return `${String(threadId || '')}\n${String(turnId || '')}`;
19
- }
20
-
21
- function normalizePermissionMode(value) {
22
- const mode = String(value || 'default');
23
- return PERMISSION_MODES.has(mode) ? mode : null;
24
- }
25
-
26
- function normalizeSandboxMode(value) {
27
- const mode = String(value || 'default');
28
- return SANDBOX_MODES.has(mode) ? mode : null;
29
- }
30
-
31
- function sandboxPolicyFor(mode, workingDir, workspaceOptions = {}) {
32
- if (mode === 'danger-full-access') return { type: 'dangerFullAccess' };
33
- if (mode === 'read-only') return { type: 'readOnly', networkAccess: false };
34
- if (mode === 'workspace-write') {
35
- const roots = Array.isArray(workspaceOptions.writable_roots) ? workspaceOptions.writable_roots : [];
36
- return { type: 'workspaceWrite', writableRoots: [workingDir, ...roots.filter(root => root !== workingDir)],
37
- networkAccess: Boolean(workspaceOptions.network_access),
38
- excludeTmpdirEnvVar: Boolean(workspaceOptions.exclude_tmpdir_env_var),
39
- excludeSlashTmp: Boolean(workspaceOptions.exclude_slash_tmp) };
40
- }
41
- return null;
42
- }
43
-
44
- function sandboxModeFromPolicy(policy) {
45
- const type = typeof policy === 'string' ? policy : policy?.type;
46
- if (type === 'dangerFullAccess' || type === 'danger-full-access') return 'danger-full-access';
47
- if (type === 'readOnly' || type === 'read-only') return 'read-only';
48
- if (type === 'workspaceWrite' || type === 'workspace-write') return 'workspace-write';
49
- return null;
50
- }
51
-
52
- function safeJson(value) {
53
- try { return JSON.stringify(value || {}, null, 2); } catch (_) { return String(value || ''); }
54
- }
55
-
56
- function toTimestampMs(value) {
57
- const timestamp = Number(value || 0);
58
- if (!timestamp) return null;
59
- return timestamp < 100000000000 ? timestamp * 1000 : timestamp;
60
- }
61
-
62
- function appServerSpawnOptions({ cwd, env, platform = process.platform }) {
63
- const options = { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] };
64
-
65
- // Globally installed npm CLIs expose a .cmd shim on Windows. child_process.spawn
66
- // does not resolve that shim without a shell, causing `spawn codex ENOENT`.
67
- if (platform === 'win32') {
68
- options.shell = true;
69
- options.windowsHide = true;
70
- } else {
71
- // Keep the npm wrapper, native Codex binary, and MCP children in one group
72
- // so a forced abort can stop the complete app-server process tree.
73
- options.detached = true;
74
- }
75
-
76
- return options;
77
- }
78
-
79
- function reserveLoopbackPort() {
80
- return new Promise((resolve, reject) => {
81
- const server = net.createServer();
82
- server.unref();
83
- server.once('error', reject);
84
- server.listen(0, '127.0.0.1', () => {
85
- const address = server.address();
86
- const port = typeof address === 'object' && address ? address.port : 0;
87
- server.close(error => {
88
- if (error) reject(error);
89
- else if (!port) reject(new Error('Unable to reserve a loopback port for Codex app-server'));
90
- else resolve(port);
91
- });
92
- });
93
- });
94
- }
95
-
96
- async function connectAppServerWebSocket(url, timeoutMs = APP_SERVER_CONNECT_TIMEOUT_MS) {
97
- const deadline = Date.now() + timeoutMs;
98
- let lastError = new Error('Codex app-server WebSocket did not become ready');
99
- while (Date.now() < deadline) {
100
- try {
101
- return await new Promise((resolve, reject) => {
102
- const socket = new WebSocket(url);
103
- const onOpen = () => {
104
- socket.off('error', onError);
105
- resolve(socket);
106
- };
107
- const onError = error => {
108
- socket.off('open', onOpen);
109
- socket.terminate();
110
- reject(error);
111
- };
112
- socket.once('open', onOpen);
113
- socket.once('error', onError);
114
- });
115
- } catch (error) {
116
- lastError = error;
117
- await new Promise(resolve => setTimeout(resolve, 50));
118
- }
119
- }
120
- throw lastError;
121
- }
122
-
123
- function forceKillProcessTree(child, options = {}) {
124
- const platform = options.platform || process.platform;
125
- const killGroup = options.killGroup || process.kill;
126
- const spawnProcess = options.spawnProcess || spawn;
127
- if (!child?.pid) return false;
128
- if (platform !== 'win32') {
129
- try {
130
- killGroup(-child.pid, 'SIGKILL');
131
- return true;
132
- } catch (_error) {
133
- try { return child.kill('SIGKILL'); } catch (_) { return false; }
134
- }
135
- }
136
- const killer = spawnProcess('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
137
- stdio: 'ignore', windowsHide: true
138
- });
139
- killer.once('error', () => {
140
- try { child.kill('SIGKILL'); } catch (_) { /* process already exited */ }
141
- });
142
- return true;
143
- }
144
-
145
- function textFromInputItems(content) {
146
- return (Array.isArray(content) ? content : [])
147
- .filter(item => item && item.type === 'text')
148
- .map(item => item.text || '')
149
- .join('\n');
150
- }
151
-
152
- function skillsFromInputItems(content) {
153
- const seen = new Set();
154
- const skills = [];
155
- for (const item of Array.isArray(content) ? content : []) {
156
- if (!item || item.type !== 'skill') continue;
157
- const name = String(item.name || '').trim();
158
- const path = String(item.path || '').trim();
159
- const key = `${name}\n${path}`;
160
- if (!name || !path || seen.has(key)) continue;
161
- seen.add(key);
162
- skills.push({ name, path });
163
- }
164
- return skills;
165
- }
166
-
167
- function recentUserQuestions(thread, limit = 2) {
168
- const questions = [];
169
- const turns = Array.isArray(thread?.turns) ? thread.turns : [];
170
- for (let turnIndex = turns.length - 1; turnIndex >= 0 && questions.length < limit; turnIndex -= 1) {
171
- const items = Array.isArray(turns[turnIndex]?.items) ? turns[turnIndex].items : [];
172
- for (let itemIndex = items.length - 1; itemIndex >= 0 && questions.length < limit; itemIndex -= 1) {
173
- const item = items[itemIndex];
174
- if (item?.type !== 'userMessage') continue;
175
- const text = (textFromInputItems(item.content) || item.text || '').trim();
176
- if (text) questions.push(text);
177
- }
178
- }
179
- while (questions.length < limit) questions.push('');
180
- return questions;
181
- }
182
-
183
- function userPromptsFromThread(thread, fallbackTimestamp = null) {
184
- const prompts = [];
185
- const threadId = String(thread?.id || '');
186
- for (const turn of Array.isArray(thread?.turns) ? thread.turns : []) {
187
- const turnTimestamp = toTimestampMs(turn.startedAt || turn.createdAt || turn.completedAt || turn.updatedAt)
188
- || fallbackTimestamp;
189
- for (const item of Array.isArray(turn?.items) ? turn.items : []) {
190
- if (item?.type !== 'userMessage') continue;
191
- const prompt = (textFromInputItems(item.content) || item.text || '').trim();
192
- if (!prompt) continue;
193
- prompts.push({
194
- id: String(item.id || `${threadId}:${turn.id || 'turn'}:${prompts.length}`),
195
- threadId,
196
- text: prompt,
197
- createdAt: toTimestampMs(item.createdAt || item.updatedAt) || turnTimestamp || null
198
- });
199
- }
200
- }
201
- return prompts;
202
- }
203
-
204
- function toolDetails(raw) {
205
- if (raw.type === 'commandExecution') {
206
- return {
207
- name: 'CodexBash',
208
- title: 'Command',
209
- command: String(raw.command || ''),
210
- cwd: String(raw.cwd || ''),
211
- input: { command: raw.command || '', cwd: raw.cwd || '' },
212
- result: typeof raw.aggregatedOutput === 'string' ? raw.aggregatedOutput : '',
213
- exitCode: raw.exitCode ?? null
214
- };
215
- }
216
- if (raw.type === 'fileChange') {
217
- return {
218
- name: 'CodexPatch',
219
- title: 'Apply patch',
220
- changes: raw.changes || [],
221
- input: { changes: raw.changes || [] },
222
- result: ''
223
- };
224
- }
225
- if (raw.type === 'mcpToolCall') {
226
- const server = String(raw.server || 'MCP');
227
- const tool = String(raw.tool || 'tool');
228
- return {
229
- name: 'McpTool',
230
- title: `${server}.${tool}`,
231
- server,
232
- tool,
233
- input: raw.arguments || {},
234
- result: raw.error != null ? String(raw.error) : safeJson(raw.result || ''),
235
- error: raw.error != null ? String(raw.error) : null
236
- };
237
- }
238
- if (raw.type === 'collabAgentToolCall') {
239
- const receiverThreadIds = Array.isArray(raw.receiverThreadIds) ? raw.receiverThreadIds.filter(Boolean) : [];
240
- const input = raw.arguments || raw.input || {
241
- ...(raw.prompt ? { prompt: raw.prompt } : {}),
242
- ...(receiverThreadIds.length ? { receiverThreadIds } : {}),
243
- ...(raw.agentsStates && Object.keys(raw.agentsStates).length ? { agentsStates: raw.agentsStates } : {})
244
- };
245
- return {
246
- name: 'Agent',
247
- title: raw.tool || raw.action || 'Subagent',
248
- tool: raw.tool || raw.action || 'subagent',
249
- input,
250
- result: raw.error != null ? String(raw.error) : raw.result == null ? '' : safeJson(raw.result),
251
- error: raw.error != null ? String(raw.error) : null,
252
- subagentId: raw.receiverThreadId || receiverThreadIds[0] || raw.agentId || null,
253
- subagentIds: receiverThreadIds,
254
- agentsStates: raw.agentsStates || {}
255
- };
256
- }
257
- return {
258
- name: raw.type === 'webSearch' ? 'WebSearch' : (raw.tool || raw.type || 'Tool'),
259
- title: raw.tool || raw.type || 'Tool',
260
- input: raw.arguments || raw.input || raw,
261
- result: raw.error != null ? String(raw.error) : safeJson(raw.result || ''),
262
- error: raw.error != null ? String(raw.error) : null
263
- };
264
- }
265
-
266
- function codexReconnectProgress(message) {
267
- const match = String(message || '').match(/\bReconnecting(?:\.\.\.)?\s*(\d+)\s*\/\s*(\d+)\b/i);
268
- if (!match) return null;
269
- return { attempt: Number(match[1]), maximum: Number(match[2]) };
270
- }
271
-
272
- class CodexStructuredSession extends EventEmitter {
273
- constructor({ id, tool, workingDir, name, logger, options = {} }) {
274
- super();
275
- this.id = id;
276
- this.tool = tool;
277
- this.name = name || tool.displayName;
278
- this.workingDir = workingDir;
279
- this.logger = logger || console;
280
- this.kind = 'codex-structured';
281
- this.startTime = Date.now();
282
- this.running = true;
283
- this.status = 'idle';
284
- this.messages = [];
285
- this.replayingHistory = false;
286
- this.pendingPermissions = new Map();
287
- this.completedPermissions = [];
288
- this.threadId = options.resume || null;
289
- this.currentTurnId = null;
290
- this.currentTurnStartedAt = null;
291
- this.reconnectAbortTurnId = null;
292
- this.threadTurns = new Map();
293
- this.turnContexts = new Map();
294
- this.providerItemContexts = new Map();
295
- this.tokenUsage = null;
296
- this.compacting = false;
297
- this.permissionMode = normalizePermissionMode(options.permissionMode);
298
- this.sandboxMode = normalizeSandboxMode(options.sandboxMode);
299
- this.effectivePermissionMode = null;
300
- this.effectiveSandboxMode = null;
301
- this.configPermissionMode = null;
302
- this.configSandboxMode = null;
303
- this.configSandboxWorkspaceWrite = {};
304
- this.configModel = null;
305
- this.configEffort = null;
306
- this.hasModelOverride = Boolean(options.model);
307
- this.hasEffortOverride = Boolean(options.effort);
308
- this.model = options.model || null;
309
- this.effort = options.effort || null;
310
- this.models = [];
311
- this.requestId = 0;
312
- this.pendingRequests = new Map();
313
- this.process = null;
314
- this.rpcSocket = null;
315
- this.processReady = null;
316
- this.processShutdown = null;
317
- this.needsThreadResume = false;
318
- this.resuming = false;
319
- this.resumePromise = null;
320
- this.resumeTarget = null;
321
- this.aborting = false;
322
- this.abortTargets = new Map();
323
- this.abortTimer = null;
324
- this.abortGraceMs = Number(options.abortGraceMs) > 0
325
- ? Number(options.abortGraceMs) : DEFAULT_ABORT_GRACE_MS;
326
- this.forceKillProcessTree = options.forceKillProcessTree || forceKillProcessTree;
327
- this.hasUnreadCompletion = false;
328
- this.inputSeq = 0;
329
- this.completionReadInputSeq = 0;
330
- this.timedInputs = new Map();
331
- this.promptHistoryCache = null;
332
- this.deferredWarnings = null;
333
- this.activeSkill = options.activeSkill && options.activeSkill.name && options.activeSkill.path
334
- ? { name: String(options.activeSkill.name), path: String(options.activeSkill.path) }
335
- : null;
336
- this.extraSkillRoots = (Array.isArray(options.extraSkillRoots) ? options.extraSkillRoots : [])
337
- .map(value => String(value || '').trim()).filter(Boolean);
338
- }
339
-
340
- toListItem() {
341
- return { id: this.id, name: this.name, tool: this.tool.displayName, startTime: this.startTime,
342
- toolKey: this.tool.key, workingDirectory: this.workingDir, mode: 'structured',
343
- hasUnreadCompletion: Boolean(this.hasUnreadCompletion), timedInputCount: this.timedInputs.size };
344
- }
345
-
346
- snapshot() {
347
- return { id: this.id, name: this.name, tool: this.tool.displayName, toolKey: this.tool.key,
348
- status: this.status, state: this.getControlState(), messages: this.messages.map(item => this.toPublicMessage(item)),
349
- pendingPermissions: [
350
- ...this.completedPermissions,
351
- ...Array.from(this.pendingPermissions.values()).map(item => item.public)
352
- ] };
353
- }
354
-
355
- toPublicMessage(item) {
356
- if (!item || typeof item !== 'object') return item;
357
- const message = { ...item };
358
- const isSubagent = Boolean(message.threadId && this.threadId && message.threadId !== this.threadId);
359
- let hasDetail = false;
360
-
361
- if (message.kind === 'tool') {
362
- for (const field of ['result', 'input', 'changes', 'error', 'agentsStates']) {
363
- const value = message[field];
364
- if (value != null && value !== '' && (!Array.isArray(value) || value.length)) hasDetail = true;
365
- delete message[field];
366
- }
367
- if (item.error) message.hasError = true;
368
- }
369
- if (isSubagent && ['user', 'assistant', 'reasoning', 'event'].includes(message.kind)) {
370
- if (message.text) hasDetail = true;
371
- delete message.text;
372
- delete message.skills;
373
- }
374
- if (message.kind === 'reasoning') {
375
- if (message.text) hasDetail = true;
376
- delete message.text;
377
- }
378
- message.hasDetail = hasDetail;
379
- message.detailRevision = Number(item.updatedAt || item.createdAt || 0);
380
- return message;
381
- }
382
-
383
- getMessageDetails({ ids = [], threadId = null } = {}) {
384
- const requestedIds = new Set((Array.isArray(ids) ? ids : [])
385
- .map(value => String(value || '')).filter(Boolean));
386
- const requestedThreadId = threadId == null ? '' : String(threadId);
387
- const messages = this.messages.filter(item => {
388
- if (requestedThreadId && String(item.threadId || '') === requestedThreadId) return true;
389
- return requestedIds.has(String(item.id || ''));
390
- }).map(item => ({ ...item, detailLoaded: true }));
391
- return { messages, threadId: requestedThreadId || null };
392
- }
393
-
394
- getControlState() {
395
- const activeSubagentCount = Array.from(this.threadTurns.entries())
396
- .filter(([threadId, turn]) => threadId !== this.threadId && turn?.status === 'running').length;
397
- return { permissionMode: this.permissionMode || 'default', sandboxMode: this.sandboxMode || 'default',
398
- effectivePermissionMode: this.effectivePermissionMode, effectiveSandboxMode: this.effectiveSandboxMode,
399
- model: this.model, effort: this.effort,
400
- status: this.status, threadId: this.threadId,
401
- aborting: this.aborting, resuming: this.resuming,
402
- canAbort: (this.status !== 'idle' || this.resuming) && !this.aborting,
403
- canCompact: this.status === 'idle' && !this.compacting
404
- && !this.aborting && !this.resuming && Boolean(this.threadId),
405
- compacting: this.compacting,
406
- pendingPermissionCount: this.pendingPermissions.size, activeSubagentCount, models: this.models };
407
- }
408
-
409
- getHistory() {
410
- const text = this.messages.map(item => {
411
- if (item.kind === 'user') return `User: ${item.text}`;
412
- if (item.kind === 'assistant') return `Codex: ${item.text}`;
413
- if (item.kind === 'tool') return `Tool ${item.name}: ${item.summary || ''}`;
414
- return item.text || '';
415
- }).filter(Boolean).join('\n\n');
416
- return { success: true, sessionId: this.id, sessionName: this.name, tool: this.tool.displayName,
417
- historyMode: 'structured', text, updatedAt: Date.now(),
418
- truncated: false, bytes: Buffer.byteLength(text, 'utf8'), lines: text ? text.split('\n').length : 0 };
419
- }
420
-
421
- getCatchupOutput() {
422
- return { source: 'codex-structured', items: this.messages.length, data: '' };
423
- }
424
- isRunning() { return this.running; }
425
-
426
- createItem(item) { return { id: crypto.randomUUID(), createdAt: Date.now(), ...item }; }
427
- append(item) {
428
- const next = this.createItem(item);
429
- this.messages.push(next);
430
- if (!this.replayingHistory) this.emitEvent({ type: 'message', message: this.toPublicMessage(next) });
431
- return next;
432
- }
433
- patch(id, patch) {
434
- const item = this.messages.find(message => message.id === id);
435
- if (!item) return null;
436
- Object.assign(item, { updatedAt: Date.now() }, patch);
437
- if (!this.replayingHistory) this.emitEvent({ type: 'message-updated', message: this.toPublicMessage(item) });
438
- return item;
439
- }
440
- emitEvent(event) { this.emit('event', event); }
441
- setStatus(status) { if (this.status !== status) { this.status = status; this.emitEvent({ type: 'state', state: this.getControlState() }); } }
442
- emitControlState() { this.emitEvent({ type: 'state', state: this.getControlState() }); }
443
- recordPermission(request, status, decision) {
444
- const completed = { ...request, status, decision };
445
- this.completedPermissions = [...this.completedPermissions.filter(item => item.id !== request.id), completed].slice(-50);
446
- this.emitEvent({ type: 'permission-updated', request: completed });
447
- return completed;
448
- }
449
-
450
- clearAbortState(emit = true) {
451
- const changed = this.aborting;
452
- if (this.abortTimer) clearTimeout(this.abortTimer);
453
- this.abortTimer = null;
454
- this.abortTargets.clear();
455
- this.aborting = false;
456
- if (emit && changed) this.emitControlState();
457
- }
458
-
459
- finishAbortIfComplete() {
460
- if (!this.aborting || this.abortTargets.size) return false;
461
- this.clearAbortState(false);
462
- const hasActiveTurn = this.currentTurnId
463
- || Array.from(this.threadTurns.values()).some(turn => turn?.status === 'running');
464
- if (!hasActiveTurn && this.status !== 'idle') this.setStatus('idle');
465
- else this.emitControlState();
466
- return true;
467
- }
468
-
469
- async ensureProcess() {
470
- if (this.processShutdown) await this.processShutdown;
471
- if (this.processReady) return this.processReady;
472
- let startup;
473
- startup = (async () => {
474
- const port = await reserveLoopbackPort();
475
- return new Promise((resolve, reject) => {
476
- const listenUrl = `ws://127.0.0.1:${port}`;
477
- // ARM64 Docker 中的大响应可能让非阻塞 stdio pipe 返回 EAGAIN。
478
- // loopback WebSocket 保持连接只在容器内部可见,同时避开该传输缺陷。
479
- const child = spawn(this.tool.command, ['app-server', '--listen', listenUrl], appServerSpawnOptions({
480
- cwd: this.workingDir,
481
- env: { ...process.env }
482
- }));
483
- this.process = child;
484
- const fail = error => {
485
- if (this.process === child) void this.disconnectProcess(true, error);
486
- if (this.processReady === startup) this.processReady = null;
487
- reject(error instanceof Error ? error : new Error(String(error)));
488
- };
489
- const transportFailed = error => {
490
- if (this.process !== child) return;
491
- const failure = error instanceof Error ? error : new Error(String(error || 'Codex app-server transport closed'));
492
- this.logger.debugInfo?.(`[codex-app-server] transport failed: ${failure.message}`);
493
- this.needsThreadResume = Boolean(this.threadId || this.resumeTarget);
494
- if (this.running) {
495
- const activeTurn = Boolean(this.currentTurnId || this.status === 'running' || this.status === 'waiting_approval');
496
- this.clearAbortState(false);
497
- this.compacting = false;
498
- this.append({ kind: 'event', level: 'error', text: `Codex app-server connection failed: ${failure.message}` });
499
- this.emitEvent({ type: 'runtime-disconnected', activeTurn, turnId: this.currentTurnId || null });
500
- if (this.status !== 'idle') this.setStatus('idle');
501
- else this.emitControlState();
502
- }
503
- void this.disconnectProcess(true, failure);
504
- };
505
- child.once('error', transportFailed);
506
- child.once('exit', code => {
507
- if (this.process !== child) return;
508
- this.process = null;
509
- this.processReady = null;
510
- const socket = this.rpcSocket;
511
- this.rpcSocket = null;
512
- socket?.terminate();
513
- for (const request of this.pendingRequests.values()) {
514
- clearTimeout(request.timer);
515
- request.reject(new Error(`Codex app-server exited (${code})`));
516
- }
517
- this.pendingRequests.clear();
518
- if (this.running) {
519
- const activeTurn = Boolean(this.currentTurnId || this.status === 'running' || this.status === 'waiting_approval');
520
- this.needsThreadResume = Boolean(this.threadId);
521
- this.clearAbortState(false);
522
- this.compacting = false;
523
- this.append({ kind: 'event', level: 'error', text: 'Codex app-server exited.' });
524
- this.emitEvent({ type: 'runtime-disconnected', activeTurn, turnId: this.currentTurnId || null });
525
- this.setStatus('idle');
526
- }
527
- });
528
- const logOutput = data => this.logger.debugInfo?.(`[codex-app-server] ${String(data).trim()}`);
529
- child.stdout.on('data', logOutput);
530
- child.stderr.on('data', logOutput);
531
- connectAppServerWebSocket(listenUrl).then(socket => {
532
- if (this.process !== child) {
533
- socket.terminate();
534
- throw new Error('Codex app-server stopped while connecting');
535
- }
536
- this.rpcSocket = socket;
537
- socket.on('message', data => this.handleRpcLine(String(data)));
538
- socket.once('error', transportFailed);
539
- socket.once('close', (code, reason) => {
540
- transportFailed(new Error(`Codex app-server WebSocket closed (${code}${reason?.length ? `: ${String(reason)}` : ''})`));
541
- });
542
- return this.request('initialize', {
543
- clientInfo: { name: 'glad-web', title: 'Glad', version: '1.0' },
544
- capabilities: { experimentalApi: true }
545
- }, { fatalOnTimeout: true });
546
- }).then(async () => {
547
- this.notify('initialized', {});
548
- if (this.extraSkillRoots.length) {
549
- await this.request('skills/extraRoots/set', { extraRoots: this.extraSkillRoots }, { fatalOnTimeout: true });
550
- }
551
- try { await this.refreshConfigDefaults(); } catch (error) { this.logger.debugInfo?.(`[codex-app-server] config/read failed: ${error.message}`); }
552
- try { await this.refreshModels(); } catch (error) { this.logger.debugInfo?.(`[codex-app-server] model/list failed: ${error.message}`); }
553
- resolve();
554
- }).catch(fail);
555
- });
556
- })();
557
- this.processReady = startup;
558
- try {
559
- return await startup;
560
- } catch (error) {
561
- if (this.processReady === startup) this.processReady = null;
562
- throw error;
563
- }
564
- }
565
-
566
- request(method, params, options = {}) {
567
- if (!this.process || this.rpcSocket?.readyState !== WebSocket.OPEN) {
568
- return Promise.reject(new Error('Codex app-server is not connected'));
569
- }
570
- const child = this.process;
571
- const socket = this.rpcSocket;
572
- const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : DEFAULT_REQUEST_TIMEOUT_MS;
573
- const fatalOnTimeout = Boolean(options.fatalOnTimeout);
574
- const id = ++this.requestId;
575
- return new Promise((resolve, reject) => {
576
- const fail = error => {
577
- const pending = this.pendingRequests.get(id);
578
- if (!pending) return;
579
- clearTimeout(pending.timer);
580
- this.pendingRequests.delete(id);
581
- reject(error);
582
- };
583
- const timer = setTimeout(() => {
584
- const error = new Error(`${method} timed out after ${Math.max(1, Math.round(timeoutMs / 1000))} seconds`);
585
- fail(error);
586
- // 生命周期请求超时后连接状态未知,必须启动新的 app-server。
587
- if (fatalOnTimeout && this.process === child) void this.disconnectProcess(true, error);
588
- }, timeoutMs);
589
- this.pendingRequests.set(id, { resolve, reject, timer });
590
- try {
591
- socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }), error => {
592
- if (!error) return;
593
- fail(error);
594
- if (this.process === child) void this.disconnectProcess(true, error);
595
- });
596
- } catch (error) {
597
- fail(error);
598
- if (this.process === child) void this.disconnectProcess(true, error);
599
- }
600
- });
601
- }
602
-
603
- notify(method, params) {
604
- return this.sendRpcMessage({ jsonrpc: '2.0', method, params });
605
- }
606
-
607
- respond(id, result) {
608
- return this.sendRpcMessage({ jsonrpc: '2.0', id, result });
609
- }
610
-
611
- sendRpcMessage(message) {
612
- if (!this.process || this.rpcSocket?.readyState !== WebSocket.OPEN) return false;
613
- const child = this.process;
614
- try {
615
- this.rpcSocket.send(JSON.stringify(message), error => {
616
- if (error && this.process === child) void this.disconnectProcess(true, error);
617
- });
618
- return true;
619
- } catch (error) {
620
- if (this.process === child) void this.disconnectProcess(true, error);
621
- return false;
622
- }
623
- }
624
-
625
- handleRpcLine(line) {
626
- let message;
627
- try { message = JSON.parse(line); } catch (_) { return; }
628
- if (message.id !== undefined && !message.method) {
629
- const request = this.pendingRequests.get(message.id);
630
- if (!request) return;
631
- this.pendingRequests.delete(message.id);
632
- clearTimeout(request.timer);
633
- if (message.error) request.reject(new Error(message.error.message || 'Codex RPC error'));
634
- else request.resolve(message.result);
635
- return;
636
- }
637
- if (message.id !== undefined && message.method) {
638
- this.handleServerRequest(message);
639
- return;
640
- }
641
- if (message.method) this.handleNotification(message.method, message.params || {});
642
- }
643
-
644
- handleServerRequest(message) {
645
- const params = message.params || {};
646
- if (message.method === 'mcpServer/elicitation/request') {
647
- const toolMatch = typeof params.message === 'string' ? params.message.match(/tool "([^"]+)"/i) : null;
648
- const id = String(params.callId || `${params.serverName || 'mcp'}:${message.id}`);
649
- const toolName = toolMatch?.[1] || params.serverName || 'MCP tool';
650
- const publicRequest = { id, status: 'pending', title: toolName, toolName,
651
- input: params._meta?.tool_params || {}, reason: params.message || '', canAllowTool: true };
652
- this.pendingPermissions.set(id, { rpcId: message.id, public: publicRequest, method: message.method, params });
653
- this.setStatus('waiting_approval');
654
- this.emitEvent({ type: 'permission-request', request: publicRequest });
655
- return;
656
- }
657
- if (message.method === 'item/tool/requestUserInput') {
658
- this.append({ kind: 'event', level: 'warning', text: 'Codex requested additional input in Terminal-compatible form. The request was skipped.' });
659
- this.respond(message.id, { answers: {} });
660
- return;
661
- }
662
- if (['item/commandExecution/requestApproval', 'item/fileChange/requestApproval', 'item/permissions/requestApproval'].includes(message.method)) {
663
- const id = String(params.itemId || params.callId || params.approvalId || message.id);
664
- const name = message.method.includes('fileChange') ? 'File change' : message.method.includes('permissions') ? 'Permission request' : 'Command execution';
665
- const publicRequest = { id, status: 'pending', title: name, toolName: name,
666
- input: params, reason: params.reason || '', canAllowTool: false };
667
- this.pendingPermissions.set(id, { rpcId: message.id, public: publicRequest, method: message.method });
668
- this.setStatus('waiting_approval');
669
- this.emitEvent({ type: 'permission-request', request: publicRequest });
670
- return;
671
- }
672
- this.respond(message.id, null);
673
- }
674
-
675
- handleNotification(method, params) {
676
- if (method === 'thread/tokenUsage/updated') {
677
- this.tokenUsage = params.tokenUsage || params.usage || params;
678
- this.recordTurnContext(params.turnId, this.tokenUsage);
679
- return;
680
- }
681
- if (method === 'turn/started') {
682
- const threadId = params.threadId || this.threadId;
683
- const turnId = params.turn?.id || params.turnId || null;
684
- const startedAt = Number(params.turn?.startedAt || 0);
685
- const startedAtMs = startedAt > 0 && startedAt < 100000000000 ? startedAt * 1000 : startedAt || Date.now();
686
- if (threadId && turnId) this.threadTurns.set(threadId, { turnId, startedAt: startedAtMs, status: 'running' });
687
- if (!threadId || threadId === this.threadId) {
688
- this.currentTurnId = turnId || this.currentTurnId;
689
- this.currentTurnStartedAt = startedAtMs;
690
- this.setStatus('running');
691
- } else {
692
- this.emitControlState();
693
- }
694
- this.append({ kind: 'turn-start', threadId, turnId, createdAt: startedAtMs });
695
- return;
696
- }
697
- if (method === 'turn/completed') {
698
- const threadId = params.threadId || this.threadId;
699
- const trackedTurn = threadId ? this.threadTurns.get(threadId) : null;
700
- const completedTurnId = params.turn?.id || params.turnId || trackedTurn?.turnId || this.currentTurnId;
701
- const turnStatus = params.turn?.status === 'failed' || params.turn?.error ? 'failed'
702
- : params.turn?.status === 'interrupted' ? 'cancelled' : 'completed';
703
- const completedAt = Number(params.turn?.completedAt || 0);
704
- const completedAtMs = completedAt > 0 && completedAt < 100000000000 ? completedAt * 1000 : completedAt || Date.now();
705
- const startedAtMs = trackedTurn?.startedAt || ((!threadId || threadId === this.threadId) ? this.currentTurnStartedAt : null);
706
- const durationMs = Number(params.turn?.durationMs || 0)
707
- || (startedAtMs ? Math.max(0, completedAtMs - startedAtMs) : null);
708
- const context = this.turnContexts.get(String(completedTurnId || ''));
709
- const existingTurnEnd = this.messages.find(item => item.kind === 'turn-end'
710
- && item.threadId === threadId && item.turnId === completedTurnId);
711
- if (existingTurnEnd) {
712
- this.patch(existingTurnEnd.id, { status: turnStatus, durationMs,
713
- createdAt: completedAtMs, ...(context ? { context } : {}) });
714
- } else {
715
- this.append({ kind: 'turn-end', threadId, turnId: completedTurnId, status: turnStatus,
716
- durationMs, createdAt: completedAtMs, ...(context ? { context } : {}) });
717
- }
718
- this.abortTargets.delete(turnKey(threadId, completedTurnId));
719
- const observedNow = Date.now();
720
- const observedCompletedAtMs = Math.abs(observedNow - completedAtMs) < 5000
721
- ? Math.max(completedAtMs, observedNow) : completedAtMs;
722
- for (const item of this.messages.filter(message => message.kind === 'tool'
723
- && message.turnId === completedTurnId && ['running', 'inProgress'].includes(message.toolStatus))) {
724
- const toolDurationMs = Number(item.durationMs || 0)
725
- || (item.startedAtMs || item.createdAt ? Math.max(1, observedCompletedAtMs - Number(item.startedAtMs || item.createdAt)) : null);
726
- const toolStatus = turnStatus === 'failed' ? 'failed' : turnStatus === 'cancelled' ? 'cancelled' : 'completed';
727
- this.patch(item.id, { toolStatus,
728
- completedAtMs: observedCompletedAtMs, ...(toolDurationMs != null ? { durationMs: toolDurationMs } : {}) });
729
- }
730
- for (const item of this.messages.filter(message => message.kind === 'compaction'
731
- && message.turnId === completedTurnId && message.compactionStatus === 'running')) {
732
- this.patch(item.id, { compactionStatus: 'completed', completedAtMs: observedCompletedAtMs });
733
- }
734
- if (threadId) this.threadTurns.delete(threadId);
735
- if (!threadId || threadId === this.threadId) {
736
- this.compacting = false;
737
- for (const pending of this.pendingPermissions.values()) {
738
- this.recordPermission(pending.public, 'denied', 'abort');
739
- }
740
- this.currentTurnId = null;
741
- this.currentTurnStartedAt = null;
742
- this.pendingPermissions.clear();
743
- if (params.turn?.status === 'failed' || params.turn?.error) {
744
- this.append({ kind: 'event', level: 'error', text: params.turn?.error?.message || 'Codex turn failed.' });
745
- }
746
- this.setStatus(this.aborting && this.abortTargets.size ? 'running' : 'idle');
747
- this.hasUnreadCompletion = true;
748
- } else {
749
- this.emitControlState();
750
- }
751
- this.finishAbortIfComplete();
752
- return;
753
- }
754
- if (method === 'thread/compacted') {
755
- const threadId = params.threadId || this.threadId;
756
- const turnId = params.turnId || (threadId ? this.threadTurns.get(threadId)?.turnId : null) || this.currentTurnId;
757
- this.applyProviderItem({ id: `compaction-${turnId || Date.now()}`, type: 'contextCompaction', threadId, turnId }, 'completed', {
758
- threadId, turnId, completedAtMs: Date.now()
759
- });
760
- return;
761
- }
762
- if (method === 'thread/started' || method === 'thread/resumed') {
763
- const threadId = params.thread?.id || params.threadId;
764
- if (threadId && !this.threadId) { this.threadId = threadId; this.emitControlState(); }
765
- return;
766
- }
767
- if (method === 'thread/status/changed') {
768
- const threadId = params.threadId || this.threadId;
769
- const status = params.status?.type || params.status;
770
- if (!threadId || threadId === this.threadId) {
771
- if (status === 'idle' && !this.currentTurnId) { this.compacting = false; this.setStatus('idle'); }
772
- if (status === 'active' && !this.aborting) this.setStatus('running');
773
- }
774
- return;
775
- }
776
- if (method === 'thread/settings/updated') {
777
- const settings = params.threadSettings || {};
778
- this.model = settings.model || this.model;
779
- this.effort = settings.effort || this.effort;
780
- this.effectivePermissionMode = settings.approvalPolicy || this.effectivePermissionMode;
781
- this.effectiveSandboxMode = sandboxModeFromPolicy(settings.sandboxPolicy) || this.effectiveSandboxMode;
782
- this.emitEvent({ type: 'state', state: this.getControlState() });
783
- return;
784
- }
785
- if (method === 'error') {
786
- const message = params.error?.message || 'Codex reported an error.';
787
- this.append({ kind: 'event', level: 'error', text: message });
788
- const reconnect = codexReconnectProgress(message);
789
- const turnId = String(params.turnId || this.currentTurnId || 'active');
790
- if (params.willRetry && reconnect?.attempt === 4 && reconnect.maximum === 5
791
- && this.reconnectAbortTurnId !== turnId && this.abort('Aborted after Codex reconnect attempt 4/5.')) {
792
- this.reconnectAbortTurnId = turnId;
793
- this.emitEvent({ type: 'runtime-disconnected', activeTurn: true, turnId });
794
- }
795
- if (!params.willRetry) { this.compacting = false; this.setStatus('idle'); }
796
- return;
797
- }
798
- if (method === 'warning' || method === 'guardianWarning') {
799
- const warning = { kind: 'event', level: 'warning', text: params.message || params.warning || 'Codex warning.' };
800
- if (this.deferredWarnings) this.deferredWarnings.push(warning);
801
- else this.append(warning);
802
- return;
803
- }
804
- if (method === 'item/commandExecution/outputDelta' || method === 'item/fileChange/outputDelta') {
805
- const target = this.messages.find(item => item.providerId === String(params.itemId || '') && item.kind === 'tool');
806
- if (target) this.patch(target.id, { result: String(target.result || '') + String(params.delta || '') });
807
- return;
808
- }
809
- if (method === 'item/plan/delta') {
810
- const providerId = String(params.itemId || '');
811
- const target = this.messages.find(item => item.providerId === providerId && item.kind === 'reasoning');
812
- const known = this.providerItemContexts.get(providerId) || {};
813
- const threadId = params.threadId || target?.threadId || known.threadId || null;
814
- const turnId = params.turnId || target?.turnId || known.turnId
815
- || (threadId ? this.threadTurns.get(threadId)?.turnId : null) || this.currentTurnId;
816
- if (providerId && (threadId || turnId)) this.providerItemContexts.set(providerId, { threadId, turnId });
817
- if (target) this.patch(target.id, { text: String(target.text || '') + String(params.delta || ''), threadId, turnId });
818
- else this.append({ kind: 'reasoning', providerId, text: String(params.delta || ''), threadId, turnId, streaming: true });
819
- return;
820
- }
821
- if (method.includes('agentMessage/delta') || method.includes('reasoning/textDelta') || method.includes('reasoning/summaryTextDelta')) {
822
- const kind = method.includes('agentMessage') ? 'assistant' : 'reasoning';
823
- const itemId = String(params.itemId || params.id || '');
824
- const target = this.messages.find(item => item.providerId === itemId && item.kind === kind);
825
- const known = this.providerItemContexts.get(itemId) || {};
826
- const threadId = params.threadId || target?.threadId || known.threadId || null;
827
- const turnId = params.turnId || target?.turnId || known.turnId
828
- || (threadId ? this.threadTurns.get(threadId)?.turnId : null) || this.currentTurnId;
829
- const delta = String(params.delta || '');
830
- if (itemId && (threadId || turnId)) this.providerItemContexts.set(itemId, { threadId, turnId });
831
- if (target) this.patch(target.id, { text: (target.text || '') + delta, threadId, turnId });
832
- else this.append({ kind, providerId: itemId, text: delta, threadId, turnId, streaming: true });
833
- return;
834
- }
835
- if (method.startsWith('item/')) {
836
- const inferredStatus = method === 'item/completed' ? 'completed' : method === 'item/started' ? 'running' : null;
837
- this.applyProviderItem(params.item || params, inferredStatus, {
838
- threadId: params.threadId || null,
839
- turnId: params.turnId || null,
840
- startedAtMs: Number(params.startedAtMs || 0) || null,
841
- completedAtMs: Number(params.completedAtMs || 0) || null
842
- });
843
- }
844
- }
845
-
846
- applyProviderItem(raw, inferredStatus = null, context = {}) {
847
- if (!raw || typeof raw !== 'object') return;
848
- const providerId = String(raw.id || '');
849
- let existing = providerId && this.messages.find(item => item.providerId === providerId);
850
- const kind = raw.type === 'userMessage' ? 'user' : raw.type === 'agentMessage' ? 'assistant' : ['reasoning', 'plan'].includes(raw.type) ? 'reasoning'
851
- : ['commandExecution', 'fileChange', 'mcpToolCall', 'dynamicToolCall', 'webSearch', 'collabAgentToolCall'].includes(raw.type) ? 'tool'
852
- : raw.type === 'contextCompaction' ? 'compaction' : null;
853
- if (!kind) return;
854
- const text = kind === 'user' ? textFromInputItems(raw.content) : kind === 'assistant' ? String(raw.text || '')
855
- : kind === 'reasoning' ? (raw.text || (Array.isArray(raw.summary) ? raw.summary.join('\n') : Array.isArray(raw.content) ? raw.content.join('\n') : '')) : '';
856
- const inferredToolStatus = existing?.toolStatus === 'cancelled' ? 'cancelled'
857
- : inferredStatus === 'completed' && ['failed', 'declined'].includes(raw.status) ? raw.status : inferredStatus;
858
- const threadId = raw.threadId || context.threadId || null;
859
- const trackedTurnId = threadId ? this.threadTurns.get(threadId)?.turnId : null;
860
- const turnId = raw.turnId || context.turnId || trackedTurnId || this.currentTurnId;
861
- if (!existing && kind === 'compaction' && turnId) {
862
- existing = this.messages.find(item => item.kind === 'compaction' && item.turnId === turnId);
863
- }
864
- if (providerId && (threadId || turnId)) this.providerItemContexts.set(providerId, { threadId, turnId });
865
- const existingStartedAtMs = existing?.startedAtMs || existing?.createdAt || null;
866
- const startedAtMs = Number(context.startedAtMs || raw.startedAtMs || 0)
867
- || toTimestampMs(raw.startedAt || raw.createdAt) || existingStartedAtMs;
868
- const completedAtMs = Number(context.completedAtMs || raw.completedAtMs || 0)
869
- || toTimestampMs(raw.completedAt || raw.updatedAt);
870
- const durationMs = Number(raw.durationMs || 0)
871
- || (completedAtMs && startedAtMs && completedAtMs >= startedAtMs ? completedAtMs - startedAtMs : null)
872
- || Number(existing?.durationMs || 0) || null;
873
- const timing = {
874
- ...(startedAtMs ? { startedAtMs } : {}),
875
- ...(completedAtMs ? { completedAtMs } : {}),
876
- ...(durationMs != null ? { durationMs } : {})
877
- };
878
- const patch = kind === 'tool' ? { ...toolDetails(raw), threadId, turnId,
879
- ...timing, toolStatus: inferredToolStatus || raw.status || 'running' }
880
- : kind === 'compaction' ? { providerId, threadId, turnId, ...timing,
881
- compactionStatus: inferredStatus || raw.status || 'running' }
882
- : { text, threadId, turnId, streaming: false,
883
- ...(kind === 'user' ? { skills: skillsFromInputItems(raw.content) } : {}),
884
- ...(completedAtMs ? { completedAtMs } : {}) };
885
- if (existing) {
886
- this.patch(existing.id, patch);
887
- } else if (kind === 'user') {
888
- const local = [...this.messages].reverse().find(item => item.kind === 'user' && !item.providerId && item.text === text);
889
- if (local) this.patch(local.id, { providerId, ...patch });
890
- else this.append({ kind, providerId, ...(startedAtMs ? { createdAt: startedAtMs } : {}), ...patch });
891
- } else {
892
- this.append({ kind, providerId, ...(startedAtMs ? { createdAt: startedAtMs } : {}), ...patch });
893
- }
894
- if (kind === 'compaction' && (!threadId || threadId === this.threadId)) {
895
- this.compacting = patch.compactionStatus === 'running';
896
- this.emitControlState();
897
- }
898
- }
899
-
900
- async refreshModels() {
901
- const models = [];
902
- let cursor = null;
903
- do {
904
- const result = await this.request('model/list', { cursor, limit: 100, includeHidden: false });
905
- for (const item of result?.data || []) models.push({ id: item.id || item.model, label: item.displayName || item.model || item.id,
906
- efforts: (item.supportedReasoningEfforts || []).map(value => value.reasoningEffort), defaultEffort: item.defaultReasoningEffort || null,
907
- isDefault: Boolean(item.isDefault), contextWindow: Number(item.contextWindow || item.context_window || 0) || null });
908
- cursor = result?.nextCursor || null;
909
- } while (cursor);
910
- this.models = models;
911
- if (!this.model) this.model = (models.find(item => item.isDefault) || models[0])?.id || null;
912
- if (!this.effort) this.effort = models.find(item => item.id === this.model)?.defaultEffort || 'medium';
913
- this.emitEvent({ type: 'state', state: this.getControlState() });
914
- return models;
915
- }
916
-
917
- async listSkills(forceReload = false) {
918
- await this.ensureProcess();
919
- const params = {
920
- cwds: [this.workingDir],
921
- forceReload: Boolean(forceReload)
922
- };
923
- const result = await this.request('skills/list', params);
924
- const entries = Array.isArray(result?.data) ? result.data : [];
925
- const entry = entries.find(item => item?.cwd === this.workingDir) || entries[0] || {};
926
- return {
927
- skills: (Array.isArray(entry.skills) ? entry.skills : []).filter(item => item?.enabled !== false),
928
- errors: Array.isArray(entry.errors) ? entry.errors : []
929
- };
930
- }
931
-
932
- async resolveSkillInputs(skills) {
933
- const requested = [
934
- ...(this.activeSkill ? [this.activeSkill] : []),
935
- ...(Array.isArray(skills) ? skills : [])
936
- ].slice(0, 8);
937
- if (!requested.length) return [];
938
- const available = await this.listSkills(false);
939
- const allowed = new Map(available.skills.map(item => [`${item.name}\n${item.path}`, item]));
940
- const seen = new Set();
941
- const resolved = [];
942
- for (const item of requested) {
943
- const key = `${String(item?.name || '')}\n${String(item?.path || '')}`;
944
- const skill = allowed.get(key);
945
- if (!skill || seen.has(key)) continue;
946
- seen.add(key);
947
- resolved.push({ type: 'skill', name: skill.name, path: skill.path });
948
- }
949
- return resolved;
950
- }
951
-
952
- async refreshConfigDefaults() {
953
- const result = await this.request('config/read', { cwd: this.workingDir, includeLayers: false });
954
- const config = result?.config || {};
955
- this.configPermissionMode = config.approval_policy || null;
956
- this.configSandboxMode = normalizeSandboxMode(config.sandbox_mode);
957
- this.configSandboxWorkspaceWrite = config.sandbox_workspace_write || {};
958
- this.configModel = config.model || null;
959
- this.configEffort = config.model_reasoning_effort || null;
960
- if (!this.permissionMode) this.effectivePermissionMode = this.configPermissionMode;
961
- if (!this.sandboxMode) this.effectiveSandboxMode = this.configSandboxMode;
962
- if (!this.hasModelOverride && this.configModel) this.model = this.configModel;
963
- if (!this.hasEffortOverride && this.configEffort) this.effort = this.configEffort;
964
- this.emitEvent({ type: 'state', state: this.getControlState() });
965
- return config;
966
- }
967
-
968
- async readRecentThread(thread, limit = RESUME_HISTORY_TURN_LIMIT, options = {}) {
969
- const summary = thread && typeof thread === 'object' ? thread : {};
970
- const embeddedTurns = Array.isArray(summary.turns) ? summary.turns.slice(-limit) : [];
971
- try {
972
- const newestFirst = [];
973
- let cursor = null;
974
- do {
975
- const result = await this.request('thread/turns/list', {
976
- threadId: summary.id,
977
- cursor,
978
- limit: Math.min(100, limit - newestFirst.length),
979
- sortDirection: 'desc',
980
- itemsView: 'full'
981
- }, options);
982
- newestFirst.push(...(Array.isArray(result?.data) ? result.data : []));
983
- cursor = result?.nextCursor || null;
984
- } while (cursor && newestFirst.length < limit);
985
- return {
986
- ...summary,
987
- // app-server 默认从新到旧返回,页面仍按时间顺序展示。
988
- turns: newestFirst.slice().reverse(),
989
- historyNextCursor: cursor
990
- };
991
- } catch (error) {
992
- this.logger.debugInfo?.(`[codex-app-server] paginated history unavailable for ${summary.id || 'unknown'}: ${error.message}`);
993
- const unsupported = /method.*not found|unsupported|experimental/i.test(String(error.message || ''));
994
- if (options.fatalOnTimeout && !unsupported) throw error;
995
- return { ...summary, turns: embeddedTurns, historyNextCursor: null };
996
- }
997
- }
998
-
999
- async listResumeThreads() {
1000
- await this.ensureProcess();
1001
- const result = await this.request('thread/list', {
1002
- cursor: null,
1003
- limit: 40,
1004
- sortKey: 'updated_at',
1005
- sortDirection: 'desc',
1006
- archived: false,
1007
- cwd: this.workingDir
1008
- });
1009
- const threads = (result?.data || []).filter(item => !item.parentThreadId);
1010
- const items = [];
1011
- for (const item of threads) {
1012
- let questions = [];
1013
- try {
1014
- const history = await this.readRecentThread(item, 8);
1015
- questions = recentUserQuestions(history);
1016
- } catch (error) {
1017
- this.logger.debugInfo?.(`[codex-app-server] unable to read resume preview for ${item.id}: ${error.message}`);
1018
- }
1019
- if (!questions[0]) questions[0] = item.preview || '';
1020
- if (questions.length < 2) questions.push('');
1021
- items.push({
1022
- id: item.id,
1023
- sessionId: item.sessionId || item.id,
1024
- questions: questions.slice(0, 2),
1025
- updatedAt: Number(item.updatedAt || item.createdAt || 0) * 1000,
1026
- cwd: item.cwd || '',
1027
- current: item.id === this.threadId
1028
- });
1029
- }
1030
- return items;
1031
- }
1032
-
1033
- async listPromptHistory({ offset = 0, limit = 30 } = {}) {
1034
- await this.ensureProcess();
1035
- const safeOffset = Math.max(0, Math.min(199, Number(offset) || 0));
1036
- const safeLimit = Math.max(1, Math.min(30, Number(limit) || 30));
1037
- const cacheFresh = this.promptHistoryCache
1038
- && Date.now() - this.promptHistoryCache.loadedAt < 15000;
1039
-
1040
- if (!cacheFresh) {
1041
- const prompts = [];
1042
- let cursor = null;
1043
- let pageCount = 0;
1044
- let capped = false;
1045
- do {
1046
- const result = await this.request('thread/list', {
1047
- cursor,
1048
- limit: 20,
1049
- sortKey: 'updated_at',
1050
- sortDirection: 'desc',
1051
- archived: false,
1052
- cwd: this.workingDir
1053
- });
1054
- const threads = (result?.data || []).filter(item => !item.parentThreadId);
1055
- const histories = await Promise.all(threads.map(async item => {
1056
- try {
1057
- const history = await this.readRecentThread(item, 200);
1058
- const fallbackTimestamp = toTimestampMs(item.updatedAt || item.createdAt);
1059
- return {
1060
- prompts: userPromptsFromThread(history || { id: item.id, turns: [] }, fallbackTimestamp)
1061
- .map(prompt => ({ ...prompt, threadId: prompt.threadId || item.id })),
1062
- capped: Boolean(history.historyNextCursor)
1063
- };
1064
- } catch (error) {
1065
- this.logger.debugInfo?.(`[codex-app-server] unable to read prompt history for ${item.id}: ${error.message}`);
1066
- return { prompts: [], capped: false };
1067
- }
1068
- }));
1069
- prompts.push(...histories.flatMap(history => history.prompts));
1070
- if (histories.some(history => history.capped)) capped = true;
1071
- cursor = result?.nextCursor || null;
1072
- pageCount += 1;
1073
- if (prompts.length >= 200 || pageCount >= 5) {
1074
- capped = capped || Boolean(cursor) || prompts.length > 200;
1075
- break;
1076
- }
1077
- } while (cursor);
1078
-
1079
- prompts.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0));
1080
- this.promptHistoryCache = {
1081
- loadedAt: Date.now(),
1082
- items: prompts.slice(0, 200),
1083
- capped
1084
- };
1085
- }
1086
-
1087
- const items = this.promptHistoryCache.items.slice(safeOffset, safeOffset + safeLimit);
1088
- const nextOffset = safeOffset + items.length;
1089
- return {
1090
- items,
1091
- offset: safeOffset,
1092
- nextOffset,
1093
- total: this.promptHistoryCache.items.length,
1094
- hasMore: nextOffset < this.promptHistoryCache.items.length,
1095
- capped: this.promptHistoryCache.capped
1096
- };
1097
- }
1098
-
1099
- contextStatus(tokenUsage = this.tokenUsage) {
1100
- const usage = tokenUsage || {};
1101
- const selectedModel = this.models.find(item => item.id === this.model);
1102
- const contextWindow = Number(usage.modelContextWindow || usage.model_context_window
1103
- || usage.contextWindow || usage.context_window || selectedModel?.contextWindow || 0);
1104
- const last = usage.last || usage.lastTokenUsage || usage.last_token_usage || {};
1105
- const usedTokens = Number(last.totalTokens || last.total_tokens || usage.contextTokens
1106
- || usage.context_tokens || 0);
1107
- if (!contextWindow) {
1108
- return !this.threadId && !this.tokenUsage
1109
- ? { usedTokens: 0, contextWindow: null, remainingTokens: null, remainingPercent: 100 }
1110
- : null;
1111
- }
1112
- return {
1113
- usedTokens: Math.max(0, usedTokens),
1114
- contextWindow,
1115
- remainingTokens: Math.max(0, contextWindow - usedTokens),
1116
- remainingPercent: Math.max(0, Math.min(100, Math.round((contextWindow - usedTokens) / contextWindow * 100)))
1117
- };
1118
- }
1119
-
1120
- recordTurnContext(turnId, tokenUsage = this.tokenUsage) {
1121
- const id = String(turnId || '').trim();
1122
- const context = this.contextStatus(tokenUsage);
1123
- if (!id || !context) return context;
1124
- this.turnContexts.set(id, context);
1125
- const turnEnd = this.messages.find(item => item.kind === 'turn-end' && String(item.turnId || '') === id);
1126
- if (turnEnd) this.patch(turnEnd.id, { context });
1127
- return context;
1128
- }
1129
-
1130
- async showStatus() {
1131
- await this.ensureProcess();
1132
- const accountResult = await this.request('account/read', { refreshToken: false });
1133
- const account = accountResult?.account || null;
1134
- let rateLimits = null;
1135
- if (account?.type === 'chatgpt') {
1136
- try {
1137
- const result = await this.request('account/rateLimits/read', {});
1138
- rateLimits = result?.rateLimits || null;
1139
- } catch (error) {
1140
- this.logger.debugInfo?.(`[codex-app-server] account/rateLimits/read failed: ${error.message}`);
1141
- }
1142
- }
1143
- this.append({ kind: 'status', title: 'Codex status', model: this.model, effort: this.effort,
1144
- account, rateLimits, context: this.contextStatus() });
1145
- return true;
1146
- }
1147
-
1148
- async updateSettings(settings = {}) {
1149
- const configEdits = [];
1150
- if (settings.model) configEdits.push({ keyPath: 'model', value: String(settings.model), mergeStrategy: 'upsert' });
1151
- if (settings.effort) configEdits.push({ keyPath: 'model_reasoning_effort', value: String(settings.effort), mergeStrategy: 'upsert' });
1152
- if (configEdits.length) {
1153
- await this.ensureProcess();
1154
- await this.request('config/batchWrite', { edits: configEdits });
1155
- if (settings.model) this.configModel = String(settings.model);
1156
- if (settings.effort) this.configEffort = String(settings.effort);
1157
- }
1158
- if (settings.permissionMode !== undefined) this.permissionMode = normalizePermissionMode(settings.permissionMode);
1159
- if (settings.sandboxMode !== undefined) this.sandboxMode = normalizeSandboxMode(settings.sandboxMode);
1160
- if (settings.model !== undefined) {
1161
- this.hasModelOverride = Boolean(settings.model);
1162
- this.model = settings.model || this.configModel || null;
1163
- }
1164
- if (settings.effort !== undefined) {
1165
- this.hasEffortOverride = Boolean(settings.effort);
1166
- this.effort = settings.effort || this.configEffort || null;
1167
- }
1168
- const needsConfigDefaults = (settings.permissionMode !== undefined && !this.permissionMode)
1169
- || (settings.sandboxMode !== undefined && !this.sandboxMode);
1170
- if (needsConfigDefaults) {
1171
- await this.ensureProcess();
1172
- await this.refreshConfigDefaults();
1173
- }
1174
- if (this.threadId) {
1175
- await this.ensureProcess();
1176
- const params = { threadId: this.threadId };
1177
- if (settings.permissionMode !== undefined) {
1178
- const approvalPolicy = this.permissionMode || this.configPermissionMode;
1179
- if (approvalPolicy) params.approvalPolicy = approvalPolicy;
1180
- }
1181
- if (settings.sandboxMode !== undefined) {
1182
- const sandboxPolicy = sandboxPolicyFor(this.sandboxMode || this.configSandboxMode, this.workingDir,
1183
- this.sandboxMode ? {} : this.configSandboxWorkspaceWrite);
1184
- if (sandboxPolicy) params.sandboxPolicy = sandboxPolicy;
1185
- }
1186
- if (settings.model !== undefined) params.model = this.hasModelOverride ? this.model : null;
1187
- if (settings.effort !== undefined) params.effort = this.hasEffortOverride ? this.effort : null;
1188
- if (Object.keys(params).length > 1) await this.request('thread/settings/update', params);
1189
- }
1190
- this.emitEvent({ type: 'state', state: this.getControlState() });
1191
- return this.getControlState();
1192
- }
1193
-
1194
- async resumeThreadAfterProcessRestart() {
1195
- if (!this.threadId || !this.needsThreadResume) return false;
1196
- const result = await this.request('thread/resume', this.threadResumeParams(this.threadId), {
1197
- timeoutMs: RESUME_REQUEST_TIMEOUT_MS,
1198
- fatalOnTimeout: true
1199
- });
1200
- this.needsThreadResume = false;
1201
- this.model = result.model || result.thread?.model || this.model;
1202
- this.effort = result.reasoningEffort || result.thread?.reasoningEffort || this.effort;
1203
- this.effectivePermissionMode = result.approvalPolicy || this.effectivePermissionMode;
1204
- this.effectiveSandboxMode = sandboxModeFromPolicy(result.sandbox) || this.effectiveSandboxMode;
1205
- return true;
1206
- }
1207
-
1208
- threadResumeParams(threadId) {
1209
- const params = { threadId, cwd: this.workingDir };
1210
- if (this.permissionMode) params.approvalPolicy = this.permissionMode;
1211
- if (this.sandboxMode) params.sandbox = this.sandboxMode;
1212
- if (this.hasModelOverride && this.model) params.model = this.model;
1213
- if (this.hasEffortOverride && this.effort) params.config = { model_reasoning_effort: this.effort };
1214
- return params;
1215
- }
1216
-
1217
- async sendUserMessage(text, attachments = [], skills = [], options = {}) {
1218
- const prompt = String(text || '').trim();
1219
- const agentPrompt = String(options.agentText ?? prompt).trim();
1220
- const images = (Array.isArray(attachments) ? attachments : [])
1221
- .filter(item => item && typeof item.path === 'string' && item.path);
1222
- const displayAttachments = Array.isArray(options.displayAttachments) ? options.displayAttachments : [];
1223
- if ((!agentPrompt && images.length === 0) || this.status !== 'idle' || this.aborting || this.resuming) return false;
1224
- this.hasUnreadCompletion = false;
1225
- this.promptHistoryCache = null;
1226
- this.append({
1227
- kind: 'user',
1228
- text: prompt || (displayAttachments.length ? '📎 File attachment' : '📷 Image attachment'),
1229
- attachments: [
1230
- ...images.map(item => ({ id: item.id, name: item.name || 'image' })),
1231
- ...displayAttachments
1232
- ],
1233
- skills: (Array.isArray(skills) ? skills : []).map(item => ({
1234
- name: String(item?.name || ''), path: String(item?.path || '')
1235
- })).filter(item => item.name && item.path)
1236
- });
1237
- this.setStatus('running');
1238
- try {
1239
- await this.ensureProcess();
1240
- await this.resumeThreadAfterProcessRestart();
1241
- if (!this.threadId) {
1242
- const params = { cwd: this.workingDir };
1243
- if (this.hasModelOverride) params.model = this.model;
1244
- if (this.permissionMode) params.approvalPolicy = this.permissionMode;
1245
- if (this.sandboxMode) params.sandbox = this.sandboxMode;
1246
- const started = await this.request('thread/start', params);
1247
- this.threadId = started.thread?.id;
1248
- this.needsThreadResume = false;
1249
- this.model = started.model || this.model;
1250
- this.effort = started.reasoningEffort || this.effort;
1251
- this.effectivePermissionMode = started.approvalPolicy || this.effectivePermissionMode;
1252
- this.effectiveSandboxMode = sandboxModeFromPolicy(started.sandbox) || this.effectiveSandboxMode;
1253
- this.emitEvent({ type: 'state', state: this.getControlState() });
1254
- }
1255
- const input = [];
1256
- input.push(...await this.resolveSkillInputs(skills));
1257
- if (agentPrompt) input.push({ type: 'text', text: agentPrompt });
1258
- for (const image of images) input.push({ type: 'localImage', path: image.path });
1259
- const params = { threadId: this.threadId, input, cwd: this.workingDir, summary: 'auto' };
1260
- if (this.hasModelOverride) params.model = this.model;
1261
- if (this.hasEffortOverride) params.effort = this.effort;
1262
- if (this.permissionMode) params.approvalPolicy = this.permissionMode;
1263
- const sandboxPolicy = sandboxPolicyFor(this.sandboxMode, this.workingDir);
1264
- if (sandboxPolicy) params.sandboxPolicy = sandboxPolicy;
1265
- const started = await this.request('turn/start', params);
1266
- this.currentTurnId = started?.turn?.id || started?.turnId || this.currentTurnId;
1267
- return true;
1268
- } catch (error) {
1269
- const failedAt = Date.now();
1270
- this.currentTurnId = null;
1271
- this.currentTurnStartedAt = null;
1272
- this.setStatus('idle');
1273
- this.append({ kind: 'event', level: 'error', text: `Unable to send message: ${error.message}` });
1274
- this.emitEvent({ type: 'turn-failed', createdAt: failedAt });
1275
- throw error;
1276
- }
1277
- }
1278
-
1279
- async compactContext() {
1280
- if (!this.threadId || this.status !== 'idle'
1281
- || this.aborting || this.resuming) return false;
1282
- await this.ensureProcess();
1283
- this.compacting = true;
1284
- this.setStatus('running');
1285
- try {
1286
- await this.request('thread/compact/start', { threadId: this.threadId });
1287
- return true;
1288
- } catch (error) {
1289
- this.compacting = false;
1290
- this.setStatus('idle');
1291
- this.append({ kind: 'event', level: 'error', text: `Unable to compact context: ${error.message}` });
1292
- throw error;
1293
- }
1294
- }
1295
-
1296
- write(data) {
1297
- const text = String(data || '').replace(/\r/g, '\n');
1298
- const prompt = text.trim();
1299
- if (prompt) void this.sendUserMessage(prompt).catch(error => {
1300
- this.logger.debugInfo?.(`[codex-app-server] send failed: ${error.message}`);
1301
- });
1302
- return true;
1303
- }
1304
-
1305
- respondPermission(id, decision) {
1306
- const pending = this.pendingPermissions.get(id);
1307
- if (!pending) return false;
1308
- const normalized = ['approved', 'approved_for_session', 'denied', 'abort'].includes(decision)
1309
- ? decision : (decision ? 'approved' : 'denied');
1310
- this.pendingPermissions.delete(id);
1311
- if (pending.method === 'mcpServer/elicitation/request') {
1312
- const action = normalized === 'approved' || normalized === 'approved_for_session' ? 'accept'
1313
- : normalized === 'abort' ? 'cancel' : 'decline';
1314
- this.respond(pending.rpcId, { action, content: action === 'accept' && pending.params?.mode === 'form' ? {} : null, _meta: null });
1315
- } else if (pending.method === 'item/permissions/requestApproval') {
1316
- const approved = normalized === 'approved' || normalized === 'approved_for_session';
1317
- this.respond(pending.rpcId, { permissions: approved ? (pending.public.input.permissions || {}) : {},
1318
- scope: normalized === 'approved_for_session' ? 'session' : 'turn' });
1319
- } else {
1320
- const wireDecision = normalized === 'approved' ? 'accept' : normalized === 'approved_for_session' ? 'acceptForSession'
1321
- : normalized === 'abort' ? 'cancel' : 'decline';
1322
- this.respond(pending.rpcId, { decision: wireDecision });
1323
- }
1324
- const status = normalized === 'approved' || normalized === 'approved_for_session' ? 'approved' : 'denied';
1325
- this.recordPermission(pending.public, status, normalized);
1326
- this.setStatus(this.pendingPermissions.size ? 'waiting_approval' : 'running');
1327
- return true;
1328
- }
1329
-
1330
- abort(reason = 'Aborted by user') {
1331
- if (this.status === 'idle' && !this.resuming) return false;
1332
- if (this.aborting) return true;
1333
- if (this.resuming) {
1334
- this.aborting = true;
1335
- this.needsThreadResume = Boolean(this.threadId || this.resumeTarget);
1336
- this.emitControlState();
1337
- this.append({ kind: 'event', level: 'info', text: reason });
1338
- const error = new Error('Codex resume aborted by user');
1339
- void this.disconnectProcess(true, error);
1340
- return true;
1341
- }
1342
- for (const pending of this.pendingPermissions.values()) {
1343
- const response = pending.method === 'item/permissions/requestApproval'
1344
- ? { permissions: {}, scope: 'turn' }
1345
- : pending.method === 'mcpServer/elicitation/request'
1346
- ? { action: 'cancel', content: null, _meta: null }
1347
- : { decision: 'cancel' };
1348
- this.respond(pending.rpcId, response);
1349
- this.recordPermission(pending.public, 'denied', 'abort');
1350
- }
1351
- this.pendingPermissions.clear();
1352
- const targets = Array.from(this.threadTurns.entries())
1353
- .filter(([, turn]) => turn?.turnId && turn.status === 'running')
1354
- .map(([threadId, turn]) => ({ threadId, turnId: turn.turnId }));
1355
- if (this.threadId && this.currentTurnId
1356
- && !targets.some(target => target.threadId === this.threadId && target.turnId === this.currentTurnId)) {
1357
- targets.push({ threadId: this.threadId, turnId: this.currentTurnId });
1358
- }
1359
- this.aborting = true;
1360
- this.abortTargets = new Map(targets.map(target => [turnKey(target.threadId, target.turnId), target]));
1361
- this.emitControlState();
1362
- for (const target of targets) {
1363
- this.request('turn/interrupt', target).catch(error => {
1364
- this.logger.debugInfo?.(`[codex-app-server] turn/interrupt failed for ${target.threadId}/${target.turnId}: ${error.message}`);
1365
- });
1366
- }
1367
- this.append({ kind: 'event', level: 'info', text: reason });
1368
- this.abortTimer = setTimeout(() => this.forceAbortAfterTimeout(), this.abortGraceMs);
1369
- this.abortTimer.unref?.();
1370
- return true;
1371
- }
1372
-
1373
- forceAbortAfterTimeout() {
1374
- if (!this.aborting) return false;
1375
- const targets = Array.from(this.abortTargets.values());
1376
- const completedAtMs = Date.now();
1377
- const timeoutSeconds = Math.max(1, Math.round(this.abortGraceMs / 1000));
1378
- this.append({ kind: 'event', level: 'warning',
1379
- text: `Codex did not stop within ${timeoutSeconds} seconds. Stopping its app-server.` });
1380
-
1381
- for (const target of targets) {
1382
- const tracked = this.threadTurns.get(target.threadId);
1383
- const startedAtMs = Number(tracked?.startedAt || 0)
1384
- || (target.threadId === this.threadId ? Number(this.currentTurnStartedAt || 0) : 0);
1385
- const existingTurnEnd = this.messages.find(item => item.kind === 'turn-end'
1386
- && item.threadId === target.threadId && item.turnId === target.turnId);
1387
- if (!existingTurnEnd) {
1388
- this.append({ kind: 'turn-end', threadId: target.threadId, turnId: target.turnId,
1389
- status: 'cancelled', durationMs: startedAtMs ? Math.max(0, completedAtMs - startedAtMs) : null,
1390
- createdAt: completedAtMs });
1391
- }
1392
- }
1393
- for (const item of this.messages.filter(message => message.kind === 'tool'
1394
- && ['running', 'inProgress'].includes(message.toolStatus))) {
1395
- const startedAtMs = Number(item.startedAtMs || item.createdAt || 0);
1396
- this.patch(item.id, { toolStatus: 'cancelled', completedAtMs,
1397
- ...(startedAtMs ? { durationMs: Math.max(1, completedAtMs - startedAtMs) } : {}) });
1398
- }
1399
-
1400
- this.needsThreadResume = Boolean(this.threadId);
1401
- this.currentTurnId = null;
1402
- this.currentTurnStartedAt = null;
1403
- this.threadTurns.clear();
1404
- this.pendingPermissions.clear();
1405
- this.compacting = false;
1406
- this.hasUnreadCompletion = true;
1407
- this.clearAbortState(false);
1408
- void this.disconnectProcess(true);
1409
- if (this.status !== 'idle') this.setStatus('idle');
1410
- else this.emitControlState();
1411
- this.append({ kind: 'event', level: 'info',
1412
- text: 'Codex app-server stopped. It will restart before the next message.' });
1413
- return true;
1414
- }
1415
-
1416
- resume(threadId = null) {
1417
- const target = String(threadId || this.threadId || '').trim();
1418
- if (!target || this.status !== 'idle' || this.aborting) return false;
1419
- if (this.resumePromise) return target === this.resumeTarget ? this.resumePromise : false;
1420
- this.resuming = true;
1421
- this.resumeTarget = target;
1422
- this.emitControlState();
1423
- let tracked;
1424
- tracked = this.performResume(target).finally(() => {
1425
- if (this.resumePromise !== tracked) return;
1426
- this.resumePromise = null;
1427
- this.resumeTarget = null;
1428
- this.resuming = false;
1429
- this.clearAbortState(false);
1430
- this.emitControlState();
1431
- });
1432
- this.resumePromise = tracked;
1433
- return tracked;
1434
- }
1435
-
1436
- async performResume(target) {
1437
- await this.ensureProcess();
1438
- const selectedModel = this.model;
1439
- const selectedEffort = this.effort;
1440
- const resumeWithModelOverride = Boolean(this.hasModelOverride && selectedModel);
1441
- const resumeWithEffortOverride = Boolean(this.hasEffortOverride && selectedEffort);
1442
- const params = this.threadResumeParams(target);
1443
- this.deferredWarnings = [];
1444
- try {
1445
- const result = await this.request('thread/resume', params, {
1446
- timeoutMs: RESUME_REQUEST_TIMEOUT_MS,
1447
- fatalOnTimeout: true
1448
- });
1449
- this.threadId = result.thread?.id || target;
1450
- this.needsThreadResume = false;
1451
- this.hasModelOverride = resumeWithModelOverride;
1452
- this.hasEffortOverride = resumeWithEffortOverride;
1453
- this.model = result.model || result.thread?.model || selectedModel;
1454
- this.effort = result.reasoningEffort || result.thread?.reasoningEffort || selectedEffort;
1455
- this.effectivePermissionMode = result.approvalPolicy || this.effectivePermissionMode;
1456
- this.effectiveSandboxMode = sandboxModeFromPolicy(result.sandbox) || this.effectiveSandboxMode;
1457
- const history = await this.readRecentThread({ ...(result.thread || {}), id: this.threadId },
1458
- RESUME_HISTORY_TURN_LIMIT, { timeoutMs: RESUME_REQUEST_TIMEOUT_MS, fatalOnTimeout: true });
1459
- const historyNote = history.historyNextCursor ? ' · showing the latest 50 turns' : '';
1460
- this.restoreThreadHistory(history, `Resumed Codex thread ${this.threadId}${historyNote}`, {
1461
- preserveModel: resumeWithModelOverride,
1462
- preserveEffort: resumeWithEffortOverride
1463
- });
1464
- const warnings = this.deferredWarnings;
1465
- this.deferredWarnings = null;
1466
- for (const warning of warnings) this.append(warning);
1467
- this.promptHistoryCache = null;
1468
- return true;
1469
- } catch (error) {
1470
- const aborted = this.aborting || /resume aborted/i.test(error.message);
1471
- const warnings = this.deferredWarnings || [];
1472
- this.deferredWarnings = null;
1473
- for (const warning of warnings) this.append(warning);
1474
- this.needsThreadResume = Boolean(this.threadId || target);
1475
- await this.disconnectProcess(true, error);
1476
- this.append({ kind: 'event', level: aborted ? 'info' : 'error',
1477
- text: aborted ? 'Codex conversation recovery stopped.' : `Unable to resume Codex conversation: ${error.message}` });
1478
- throw error;
1479
- }
1480
- }
1481
-
1482
- restoreThreadHistory(thread, eventText = '', options = {}) {
1483
- if (!options.preserveModel) this.model = thread?.model || this.model;
1484
- if (!options.preserveEffort) this.effort = thread?.reasoningEffort || thread?.reasoning_effort || this.effort;
1485
- this.tokenUsage = thread?.tokenUsage || thread?.token_usage || this.tokenUsage;
1486
- this.messages = [];
1487
- this.replayingHistory = true;
1488
- this.completedPermissions = [];
1489
- this.turnContexts.clear();
1490
- this.providerItemContexts.clear();
1491
- try {
1492
- for (const turn of thread?.turns || []) {
1493
- const status = turn.status === 'failed' ? 'failed' : turn.status === 'interrupted' ? 'cancelled' : 'completed';
1494
- const startedAt = Number(turn.startedAt || turn.createdAt || 0);
1495
- const completedAt = Number(turn.completedAt || turn.updatedAt || 0);
1496
- const toMilliseconds = value => value > 0 && value < 100000000000 ? value * 1000 : value;
1497
- const startedAtMs = toMilliseconds(startedAt);
1498
- const completedAtMs = toMilliseconds(completedAt);
1499
- this.append({ kind: 'turn-start', turnId: turn.id, ...(startedAtMs ? { createdAt: startedAtMs } : {}) });
1500
- for (const item of turn.items || []) {
1501
- this.applyProviderItem({ ...item, turnId: turn.id }, status === 'failed' ? 'failed' : 'completed', {
1502
- threadId: this.threadId,
1503
- startedAtMs,
1504
- completedAtMs
1505
- });
1506
- }
1507
- const durationMs = Number(turn.durationMs || turn.duration_ms || 0)
1508
- || (startedAtMs && completedAtMs && completedAtMs >= startedAtMs ? completedAtMs - startedAtMs : null);
1509
- this.append({ kind: 'turn-end', turnId: turn.id, status, durationMs,
1510
- ...(completedAtMs ? { createdAt: completedAtMs } : {}) });
1511
- }
1512
- if (eventText) this.append({ kind: 'event', level: 'info', text: eventText });
1513
- } finally {
1514
- this.replayingHistory = false;
1515
- }
1516
- this.emitEvent({ type: 'history-reset', messages: this.messages.map(item => this.toPublicMessage(item)) });
1517
- this.emitEvent({ type: 'state', state: this.getControlState() });
1518
- }
1519
-
1520
- async forkFrom(threadId) {
1521
- const sourceThreadId = String(threadId || '').trim();
1522
- if (!sourceThreadId || this.status !== 'idle'
1523
- || this.aborting || this.resuming) return false;
1524
- await this.ensureProcess();
1525
- const params = { threadId: sourceThreadId, cwd: this.workingDir, ephemeral: false, threadSource: null };
1526
- if (this.hasModelOverride) params.model = this.model;
1527
- if (this.permissionMode) params.approvalPolicy = this.permissionMode;
1528
- if (this.sandboxMode) params.sandbox = this.sandboxMode;
1529
- const result = await this.request('thread/fork', params);
1530
- const forkedThread = result?.thread;
1531
- if (!forkedThread?.id) throw new Error('Codex did not return a forked thread');
1532
- this.threadId = forkedThread.id;
1533
- this.needsThreadResume = false;
1534
- this.model = result.model || forkedThread.model || this.model;
1535
- this.effort = result.reasoningEffort || forkedThread.reasoningEffort || forkedThread.reasoning_effort || this.effort;
1536
- this.effectivePermissionMode = result.approvalPolicy || this.effectivePermissionMode;
1537
- this.effectiveSandboxMode = sandboxModeFromPolicy(result.sandbox) || this.effectiveSandboxMode;
1538
- this.restoreThreadHistory(forkedThread, `Forked from Codex thread ${sourceThreadId}`);
1539
- this.promptHistoryCache = null;
1540
- return { threadId: this.threadId };
1541
- }
1542
-
1543
- async disconnectProcess(force = false, reason = null) {
1544
- if (!this.process) return this.processShutdown || undefined;
1545
- const child = this.process;
1546
- this.process = null;
1547
- this.processReady = null;
1548
- const socket = this.rpcSocket;
1549
- this.rpcSocket = null;
1550
- socket?.terminate();
1551
- const disconnectError = reason instanceof Error ? reason : new Error('Codex app-server disconnected');
1552
- for (const request of this.pendingRequests.values()) { clearTimeout(request.timer); request.reject(disconnectError); }
1553
- this.pendingRequests.clear();
1554
- const shutdown = typeof child.once === 'function'
1555
- ? new Promise(resolve => {
1556
- let timer;
1557
- const finish = () => { clearTimeout(timer); resolve(); };
1558
- child.once('exit', finish);
1559
- timer = setTimeout(() => {
1560
- this.logger.debugInfo?.('[codex-app-server] process did not exit within 2 seconds');
1561
- finish();
1562
- }, PROCESS_SHUTDOWN_TIMEOUT_MS);
1563
- })
1564
- : Promise.resolve();
1565
- this.processShutdown = shutdown;
1566
- if (force) this.forceKillProcessTree(child);
1567
- else child.kill();
1568
- try {
1569
- await shutdown;
1570
- } finally {
1571
- if (this.processShutdown === shutdown) this.processShutdown = null;
1572
- }
1573
- }
1574
-
1575
- markCompletionRead() { this.hasUnreadCompletion = false; this.completionReadInputSeq = this.inputSeq; }
1576
- async kill() {
1577
- this.running = false;
1578
- this.clearAbortState(false);
1579
- this.resuming = false;
1580
- this.resumeTarget = null;
1581
- await this.disconnectProcess(true);
1582
- this.emit('exit');
1583
- }
1584
- }
1585
-
1586
- module.exports = CodexStructuredSession;
1587
- module.exports.appServerSpawnOptions = appServerSpawnOptions;
1588
- module.exports.forceKillProcessTree = forceKillProcessTree;
1589
- module.exports.reserveLoopbackPort = reserveLoopbackPort;
1590
- module.exports.connectAppServerWebSocket = connectAppServerWebSocket;