glad-web 1.0.40 → 1.0.43
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/lib/codex/structured-session.js +486 -95
- package/lib/commands/web.js +2 -2
- package/lib/session/session-manager.js +3 -3
- package/lib/usage/ccusage-runner.js +25 -2
- package/lib/web/codex.js +59 -13
- package/lib/web/composer.js +1 -0
- package/lib/web/core.js +14 -3
- package/lib/web/session.js +1 -1
- package/lib/web/styles.css +6 -2
- package/package.json +1 -1
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
const { EventEmitter } = require('events');
|
|
2
2
|
const { spawn } = require('child_process');
|
|
3
|
-
const
|
|
3
|
+
const net = require('net');
|
|
4
4
|
const crypto = require('crypto');
|
|
5
|
+
const WebSocket = require('ws');
|
|
5
6
|
const PTYManager = require('../session/pty-manager');
|
|
6
7
|
|
|
7
8
|
const PERMISSION_MODES = new Set(['untrusted', 'on-request', 'never']);
|
|
8
9
|
const SANDBOX_MODES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
|
|
9
10
|
const FALLBACK_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'ultra'];
|
|
11
|
+
const DEFAULT_ABORT_GRACE_MS = 5000;
|
|
12
|
+
const PROCESS_SHUTDOWN_TIMEOUT_MS = 2000;
|
|
13
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
|
14
|
+
const RESUME_REQUEST_TIMEOUT_MS = 60000;
|
|
15
|
+
const RESUME_HISTORY_TURN_LIMIT = 50;
|
|
16
|
+
const APP_SERVER_CONNECT_TIMEOUT_MS = 10000;
|
|
17
|
+
|
|
18
|
+
function turnKey(threadId, turnId) {
|
|
19
|
+
return `${String(threadId || '')}\n${String(turnId || '')}`;
|
|
20
|
+
}
|
|
10
21
|
|
|
11
22
|
function normalizePermissionMode(value) {
|
|
12
23
|
const mode = String(value || 'default');
|
|
@@ -50,18 +61,88 @@ function toTimestampMs(value) {
|
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
function appServerSpawnOptions({ cwd, env, platform = process.platform }) {
|
|
53
|
-
const options = { cwd, env, stdio: ['
|
|
64
|
+
const options = { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] };
|
|
54
65
|
|
|
55
66
|
// Globally installed npm CLIs expose a .cmd shim on Windows. child_process.spawn
|
|
56
67
|
// does not resolve that shim without a shell, causing `spawn codex ENOENT`.
|
|
57
68
|
if (platform === 'win32') {
|
|
58
69
|
options.shell = true;
|
|
59
70
|
options.windowsHide = true;
|
|
71
|
+
} else {
|
|
72
|
+
// Keep the npm wrapper, native Codex binary, and MCP children in one group
|
|
73
|
+
// so a forced abort can stop the complete app-server process tree.
|
|
74
|
+
options.detached = true;
|
|
60
75
|
}
|
|
61
76
|
|
|
62
77
|
return options;
|
|
63
78
|
}
|
|
64
79
|
|
|
80
|
+
function reserveLoopbackPort() {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const server = net.createServer();
|
|
83
|
+
server.unref();
|
|
84
|
+
server.once('error', reject);
|
|
85
|
+
server.listen(0, '127.0.0.1', () => {
|
|
86
|
+
const address = server.address();
|
|
87
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
88
|
+
server.close(error => {
|
|
89
|
+
if (error) reject(error);
|
|
90
|
+
else if (!port) reject(new Error('Unable to reserve a loopback port for Codex app-server'));
|
|
91
|
+
else resolve(port);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function connectAppServerWebSocket(url, timeoutMs = APP_SERVER_CONNECT_TIMEOUT_MS) {
|
|
98
|
+
const deadline = Date.now() + timeoutMs;
|
|
99
|
+
let lastError = new Error('Codex app-server WebSocket did not become ready');
|
|
100
|
+
while (Date.now() < deadline) {
|
|
101
|
+
try {
|
|
102
|
+
return await new Promise((resolve, reject) => {
|
|
103
|
+
const socket = new WebSocket(url);
|
|
104
|
+
const onOpen = () => {
|
|
105
|
+
socket.off('error', onError);
|
|
106
|
+
resolve(socket);
|
|
107
|
+
};
|
|
108
|
+
const onError = error => {
|
|
109
|
+
socket.off('open', onOpen);
|
|
110
|
+
socket.terminate();
|
|
111
|
+
reject(error);
|
|
112
|
+
};
|
|
113
|
+
socket.once('open', onOpen);
|
|
114
|
+
socket.once('error', onError);
|
|
115
|
+
});
|
|
116
|
+
} catch (error) {
|
|
117
|
+
lastError = error;
|
|
118
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
throw lastError;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function forceKillProcessTree(child, options = {}) {
|
|
125
|
+
const platform = options.platform || process.platform;
|
|
126
|
+
const killGroup = options.killGroup || process.kill;
|
|
127
|
+
const spawnProcess = options.spawnProcess || spawn;
|
|
128
|
+
if (!child?.pid) return false;
|
|
129
|
+
if (platform !== 'win32') {
|
|
130
|
+
try {
|
|
131
|
+
killGroup(-child.pid, 'SIGKILL');
|
|
132
|
+
return true;
|
|
133
|
+
} catch (_error) {
|
|
134
|
+
try { return child.kill('SIGKILL'); } catch (_) { return false; }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const killer = spawnProcess('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
|
138
|
+
stdio: 'ignore', windowsHide: true
|
|
139
|
+
});
|
|
140
|
+
killer.once('error', () => {
|
|
141
|
+
try { child.kill('SIGKILL'); } catch (_) { /* process already exited */ }
|
|
142
|
+
});
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
65
146
|
function textFromInputItems(content) {
|
|
66
147
|
return (Array.isArray(content) ? content : [])
|
|
67
148
|
.filter(item => item && item.type === 'text')
|
|
@@ -232,7 +313,19 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
232
313
|
this.requestId = 0;
|
|
233
314
|
this.pendingRequests = new Map();
|
|
234
315
|
this.process = null;
|
|
316
|
+
this.rpcSocket = null;
|
|
235
317
|
this.processReady = null;
|
|
318
|
+
this.processShutdown = null;
|
|
319
|
+
this.needsThreadResume = false;
|
|
320
|
+
this.resuming = false;
|
|
321
|
+
this.resumePromise = null;
|
|
322
|
+
this.resumeTarget = null;
|
|
323
|
+
this.aborting = false;
|
|
324
|
+
this.abortTargets = new Map();
|
|
325
|
+
this.abortTimer = null;
|
|
326
|
+
this.abortGraceMs = Number(options.abortGraceMs) > 0
|
|
327
|
+
? Number(options.abortGraceMs) : DEFAULT_ABORT_GRACE_MS;
|
|
328
|
+
this.forceKillProcessTree = options.forceKillProcessTree || forceKillProcessTree;
|
|
236
329
|
this.terminalSession = null;
|
|
237
330
|
this.terminalOutput = '';
|
|
238
331
|
this.hasUnreadCompletion = false;
|
|
@@ -312,10 +405,13 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
312
405
|
effectivePermissionMode: this.effectivePermissionMode, effectiveSandboxMode: this.effectiveSandboxMode,
|
|
313
406
|
model: this.model, effort: this.effort,
|
|
314
407
|
status: this.status, threadId: this.threadId, presentation: this.presentation,
|
|
315
|
-
|
|
316
|
-
|
|
408
|
+
aborting: this.aborting, resuming: this.resuming,
|
|
409
|
+
canAbort: this.presentation === 'structured' && (this.status !== 'idle' || this.resuming) && !this.aborting,
|
|
410
|
+
canCompact: this.presentation === 'structured' && this.status === 'idle' && !this.compacting
|
|
411
|
+
&& !this.aborting && !this.resuming && Boolean(this.threadId),
|
|
317
412
|
compacting: this.compacting,
|
|
318
|
-
canSwitchToTerminal: this.presentation === 'structured' && this.status === 'idle'
|
|
413
|
+
canSwitchToTerminal: this.presentation === 'structured' && this.status === 'idle'
|
|
414
|
+
&& !this.aborting && !this.resuming && Boolean(this.threadId),
|
|
319
415
|
canSwitchToStructured: this.presentation === 'terminal',
|
|
320
416
|
pendingPermissionCount: this.pendingPermissions.size, activeSubagentCount, models: this.models };
|
|
321
417
|
}
|
|
@@ -362,72 +458,176 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
362
458
|
return completed;
|
|
363
459
|
}
|
|
364
460
|
|
|
461
|
+
clearAbortState(emit = true) {
|
|
462
|
+
const changed = this.aborting;
|
|
463
|
+
if (this.abortTimer) clearTimeout(this.abortTimer);
|
|
464
|
+
this.abortTimer = null;
|
|
465
|
+
this.abortTargets.clear();
|
|
466
|
+
this.aborting = false;
|
|
467
|
+
if (emit && changed) this.emitControlState();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
finishAbortIfComplete() {
|
|
471
|
+
if (!this.aborting || this.abortTargets.size) return false;
|
|
472
|
+
this.clearAbortState(false);
|
|
473
|
+
const hasActiveTurn = this.currentTurnId
|
|
474
|
+
|| Array.from(this.threadTurns.values()).some(turn => turn?.status === 'running');
|
|
475
|
+
if (!hasActiveTurn && this.status !== 'idle') this.setStatus('idle');
|
|
476
|
+
else this.emitControlState();
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
|
|
365
480
|
async ensureProcess() {
|
|
481
|
+
if (this.processShutdown) await this.processShutdown;
|
|
366
482
|
if (this.processReady) return this.processReady;
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
this.
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
483
|
+
let startup;
|
|
484
|
+
startup = (async () => {
|
|
485
|
+
const port = await reserveLoopbackPort();
|
|
486
|
+
return new Promise((resolve, reject) => {
|
|
487
|
+
const listenUrl = `ws://127.0.0.1:${port}`;
|
|
488
|
+
// ARM64 Docker 中的大响应可能让非阻塞 stdio pipe 返回 EAGAIN。
|
|
489
|
+
// loopback WebSocket 保持连接只在容器内部可见,同时避开该传输缺陷。
|
|
490
|
+
const child = spawn(this.tool.command, ['app-server', '--listen', listenUrl], appServerSpawnOptions({
|
|
491
|
+
cwd: this.workingDir,
|
|
492
|
+
env: { ...process.env }
|
|
493
|
+
}));
|
|
494
|
+
this.process = child;
|
|
495
|
+
const fail = error => {
|
|
496
|
+
if (this.process === child) void this.disconnectProcess(true, error);
|
|
497
|
+
if (this.processReady === startup) this.processReady = null;
|
|
498
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
499
|
+
};
|
|
500
|
+
const transportFailed = error => {
|
|
501
|
+
if (this.process !== child) return;
|
|
502
|
+
const failure = error instanceof Error ? error : new Error(String(error || 'Codex app-server transport closed'));
|
|
503
|
+
this.logger.debugInfo?.(`[codex-app-server] transport failed: ${failure.message}`);
|
|
504
|
+
this.needsThreadResume = Boolean(this.threadId || this.resumeTarget);
|
|
505
|
+
if (this.running && this.presentation === 'structured') {
|
|
506
|
+
const activeTurn = Boolean(this.currentTurnId || this.status === 'running' || this.status === 'waiting_approval');
|
|
507
|
+
this.clearAbortState(false);
|
|
508
|
+
this.compacting = false;
|
|
509
|
+
this.append({ kind: 'event', level: 'error', text: `Codex app-server connection failed: ${failure.message}` });
|
|
510
|
+
this.emitEvent({ type: 'runtime-disconnected', activeTurn, turnId: this.currentTurnId || null });
|
|
511
|
+
if (this.status !== 'idle') this.setStatus('idle');
|
|
512
|
+
else this.emitControlState();
|
|
513
|
+
}
|
|
514
|
+
void this.disconnectProcess(true, failure);
|
|
515
|
+
};
|
|
516
|
+
child.once('error', transportFailed);
|
|
517
|
+
child.once('exit', code => {
|
|
518
|
+
if (this.process !== child) return;
|
|
381
519
|
this.process = null;
|
|
382
520
|
this.processReady = null;
|
|
383
|
-
|
|
521
|
+
const socket = this.rpcSocket;
|
|
522
|
+
this.rpcSocket = null;
|
|
523
|
+
socket?.terminate();
|
|
524
|
+
for (const request of this.pendingRequests.values()) {
|
|
525
|
+
clearTimeout(request.timer);
|
|
526
|
+
request.reject(new Error(`Codex app-server exited (${code})`));
|
|
527
|
+
}
|
|
384
528
|
this.pendingRequests.clear();
|
|
385
529
|
if (this.running && this.presentation === 'structured') {
|
|
386
530
|
const activeTurn = Boolean(this.currentTurnId || this.status === 'running' || this.status === 'waiting_approval');
|
|
531
|
+
this.needsThreadResume = Boolean(this.threadId);
|
|
532
|
+
this.clearAbortState(false);
|
|
387
533
|
this.compacting = false;
|
|
388
534
|
this.append({ kind: 'event', level: 'error', text: 'Codex app-server exited.' });
|
|
389
|
-
this.emitEvent({
|
|
390
|
-
type: 'runtime-disconnected',
|
|
391
|
-
activeTurn,
|
|
392
|
-
turnId: this.currentTurnId || null
|
|
393
|
-
});
|
|
535
|
+
this.emitEvent({ type: 'runtime-disconnected', activeTurn, turnId: this.currentTurnId || null });
|
|
394
536
|
this.setStatus('idle');
|
|
395
537
|
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
538
|
+
});
|
|
539
|
+
const logOutput = data => this.logger.debugInfo?.(`[codex-app-server] ${String(data).trim()}`);
|
|
540
|
+
child.stdout.on('data', logOutput);
|
|
541
|
+
child.stderr.on('data', logOutput);
|
|
542
|
+
connectAppServerWebSocket(listenUrl).then(socket => {
|
|
543
|
+
if (this.process !== child) {
|
|
544
|
+
socket.terminate();
|
|
545
|
+
throw new Error('Codex app-server stopped while connecting');
|
|
546
|
+
}
|
|
547
|
+
this.rpcSocket = socket;
|
|
548
|
+
socket.on('message', data => this.handleRpcLine(String(data)));
|
|
549
|
+
socket.once('error', transportFailed);
|
|
550
|
+
socket.once('close', (code, reason) => {
|
|
551
|
+
transportFailed(new Error(`Codex app-server WebSocket closed (${code}${reason?.length ? `: ${String(reason)}` : ''})`));
|
|
552
|
+
});
|
|
553
|
+
return this.request('initialize', {
|
|
554
|
+
clientInfo: { name: 'glad-web', title: 'Glad', version: '1.0' },
|
|
555
|
+
capabilities: { experimentalApi: true }
|
|
556
|
+
}, { fatalOnTimeout: true });
|
|
557
|
+
}).then(async () => {
|
|
558
|
+
this.notify('initialized', {});
|
|
403
559
|
try { await this.refreshConfigDefaults(); } catch (error) { this.logger.debugInfo?.(`[codex-app-server] config/read failed: ${error.message}`); }
|
|
404
560
|
try { await this.refreshModels(); } catch (error) { this.logger.debugInfo?.(`[codex-app-server] model/list failed: ${error.message}`); }
|
|
405
561
|
resolve();
|
|
406
562
|
}).catch(fail);
|
|
407
|
-
|
|
408
|
-
|
|
563
|
+
});
|
|
564
|
+
})();
|
|
565
|
+
this.processReady = startup;
|
|
566
|
+
try {
|
|
567
|
+
return await startup;
|
|
568
|
+
} catch (error) {
|
|
569
|
+
if (this.processReady === startup) this.processReady = null;
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
409
572
|
}
|
|
410
573
|
|
|
411
|
-
request(method, params) {
|
|
412
|
-
if (!this.process ||
|
|
574
|
+
request(method, params, options = {}) {
|
|
575
|
+
if (!this.process || this.rpcSocket?.readyState !== WebSocket.OPEN) {
|
|
576
|
+
return Promise.reject(new Error('Codex app-server is not connected'));
|
|
577
|
+
}
|
|
578
|
+
const child = this.process;
|
|
579
|
+
const socket = this.rpcSocket;
|
|
580
|
+
const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : DEFAULT_REQUEST_TIMEOUT_MS;
|
|
581
|
+
const fatalOnTimeout = Boolean(options.fatalOnTimeout);
|
|
413
582
|
const id = ++this.requestId;
|
|
414
583
|
return new Promise((resolve, reject) => {
|
|
415
|
-
const
|
|
584
|
+
const fail = error => {
|
|
585
|
+
const pending = this.pendingRequests.get(id);
|
|
586
|
+
if (!pending) return;
|
|
587
|
+
clearTimeout(pending.timer);
|
|
588
|
+
this.pendingRequests.delete(id);
|
|
589
|
+
reject(error);
|
|
590
|
+
};
|
|
591
|
+
const timer = setTimeout(() => {
|
|
592
|
+
const error = new Error(`${method} timed out after ${Math.max(1, Math.round(timeoutMs / 1000))} seconds`);
|
|
593
|
+
fail(error);
|
|
594
|
+
// 生命周期请求超时后连接状态未知,必须启动新的 app-server。
|
|
595
|
+
if (fatalOnTimeout && this.process === child) void this.disconnectProcess(true, error);
|
|
596
|
+
}, timeoutMs);
|
|
416
597
|
this.pendingRequests.set(id, { resolve, reject, timer });
|
|
417
|
-
|
|
598
|
+
try {
|
|
599
|
+
socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }), error => {
|
|
600
|
+
if (!error) return;
|
|
601
|
+
fail(error);
|
|
602
|
+
if (this.process === child) void this.disconnectProcess(true, error);
|
|
603
|
+
});
|
|
604
|
+
} catch (error) {
|
|
605
|
+
fail(error);
|
|
606
|
+
if (this.process === child) void this.disconnectProcess(true, error);
|
|
607
|
+
}
|
|
418
608
|
});
|
|
419
609
|
}
|
|
420
610
|
|
|
421
611
|
notify(method, params) {
|
|
422
|
-
|
|
423
|
-
this.process.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
|
|
424
|
-
return true;
|
|
612
|
+
return this.sendRpcMessage({ jsonrpc: '2.0', method, params });
|
|
425
613
|
}
|
|
426
614
|
|
|
427
615
|
respond(id, result) {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
616
|
+
return this.sendRpcMessage({ jsonrpc: '2.0', id, result });
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
sendRpcMessage(message) {
|
|
620
|
+
if (!this.process || this.rpcSocket?.readyState !== WebSocket.OPEN) return false;
|
|
621
|
+
const child = this.process;
|
|
622
|
+
try {
|
|
623
|
+
this.rpcSocket.send(JSON.stringify(message), error => {
|
|
624
|
+
if (error && this.process === child) void this.disconnectProcess(true, error);
|
|
625
|
+
});
|
|
626
|
+
return true;
|
|
627
|
+
} catch (error) {
|
|
628
|
+
if (this.process === child) void this.disconnectProcess(true, error);
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
431
631
|
}
|
|
432
632
|
|
|
433
633
|
handleRpcLine(line) {
|
|
@@ -514,8 +714,16 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
514
714
|
const durationMs = Number(params.turn?.durationMs || 0)
|
|
515
715
|
|| (startedAtMs ? Math.max(0, completedAtMs - startedAtMs) : null);
|
|
516
716
|
const context = this.turnContexts.get(String(completedTurnId || ''));
|
|
517
|
-
this.
|
|
518
|
-
|
|
717
|
+
const existingTurnEnd = this.messages.find(item => item.kind === 'turn-end'
|
|
718
|
+
&& item.threadId === threadId && item.turnId === completedTurnId);
|
|
719
|
+
if (existingTurnEnd) {
|
|
720
|
+
this.patch(existingTurnEnd.id, { status: turnStatus, durationMs,
|
|
721
|
+
createdAt: completedAtMs, ...(context ? { context } : {}) });
|
|
722
|
+
} else {
|
|
723
|
+
this.append({ kind: 'turn-end', threadId, turnId: completedTurnId, status: turnStatus,
|
|
724
|
+
durationMs, createdAt: completedAtMs, ...(context ? { context } : {}) });
|
|
725
|
+
}
|
|
726
|
+
this.abortTargets.delete(turnKey(threadId, completedTurnId));
|
|
519
727
|
const observedNow = Date.now();
|
|
520
728
|
const observedCompletedAtMs = Math.abs(observedNow - completedAtMs) < 5000
|
|
521
729
|
? Math.max(completedAtMs, observedNow) : completedAtMs;
|
|
@@ -543,11 +751,12 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
543
751
|
if (params.turn?.status === 'failed' || params.turn?.error) {
|
|
544
752
|
this.append({ kind: 'event', level: 'error', text: params.turn?.error?.message || 'Codex turn failed.' });
|
|
545
753
|
}
|
|
546
|
-
this.setStatus('idle');
|
|
754
|
+
this.setStatus(this.aborting && this.abortTargets.size ? 'running' : 'idle');
|
|
547
755
|
this.hasUnreadCompletion = true;
|
|
548
756
|
} else {
|
|
549
757
|
this.emitControlState();
|
|
550
758
|
}
|
|
759
|
+
this.finishAbortIfComplete();
|
|
551
760
|
return;
|
|
552
761
|
}
|
|
553
762
|
if (method === 'thread/compacted') {
|
|
@@ -568,7 +777,7 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
568
777
|
const status = params.status?.type || params.status;
|
|
569
778
|
if (!threadId || threadId === this.threadId) {
|
|
570
779
|
if (status === 'idle' && !this.currentTurnId) { this.compacting = false; this.setStatus('idle'); }
|
|
571
|
-
if (status === 'active') this.setStatus('running');
|
|
780
|
+
if (status === 'active' && !this.aborting) this.setStatus('running');
|
|
572
781
|
}
|
|
573
782
|
return;
|
|
574
783
|
}
|
|
@@ -760,6 +969,37 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
760
969
|
return config;
|
|
761
970
|
}
|
|
762
971
|
|
|
972
|
+
async readRecentThread(thread, limit = RESUME_HISTORY_TURN_LIMIT, options = {}) {
|
|
973
|
+
const summary = thread && typeof thread === 'object' ? thread : {};
|
|
974
|
+
const embeddedTurns = Array.isArray(summary.turns) ? summary.turns.slice(-limit) : [];
|
|
975
|
+
try {
|
|
976
|
+
const newestFirst = [];
|
|
977
|
+
let cursor = null;
|
|
978
|
+
do {
|
|
979
|
+
const result = await this.request('thread/turns/list', {
|
|
980
|
+
threadId: summary.id,
|
|
981
|
+
cursor,
|
|
982
|
+
limit: Math.min(100, limit - newestFirst.length),
|
|
983
|
+
sortDirection: 'desc',
|
|
984
|
+
itemsView: 'full'
|
|
985
|
+
}, options);
|
|
986
|
+
newestFirst.push(...(Array.isArray(result?.data) ? result.data : []));
|
|
987
|
+
cursor = result?.nextCursor || null;
|
|
988
|
+
} while (cursor && newestFirst.length < limit);
|
|
989
|
+
return {
|
|
990
|
+
...summary,
|
|
991
|
+
// app-server 默认从新到旧返回,页面仍按时间顺序展示。
|
|
992
|
+
turns: newestFirst.slice().reverse(),
|
|
993
|
+
historyNextCursor: cursor
|
|
994
|
+
};
|
|
995
|
+
} catch (error) {
|
|
996
|
+
this.logger.debugInfo?.(`[codex-app-server] paginated history unavailable for ${summary.id || 'unknown'}: ${error.message}`);
|
|
997
|
+
const unsupported = /method.*not found|unsupported|experimental/i.test(String(error.message || ''));
|
|
998
|
+
if (options.fatalOnTimeout && !unsupported) throw error;
|
|
999
|
+
return { ...summary, turns: embeddedTurns, historyNextCursor: null };
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
763
1003
|
async listResumeThreads() {
|
|
764
1004
|
await this.ensureProcess();
|
|
765
1005
|
const result = await this.request('thread/list', {
|
|
@@ -775,8 +1015,8 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
775
1015
|
for (const item of threads) {
|
|
776
1016
|
let questions = [];
|
|
777
1017
|
try {
|
|
778
|
-
const history = await this.
|
|
779
|
-
questions = recentUserQuestions(history
|
|
1018
|
+
const history = await this.readRecentThread(item, 8);
|
|
1019
|
+
questions = recentUserQuestions(history);
|
|
780
1020
|
} catch (error) {
|
|
781
1021
|
this.logger.debugInfo?.(`[codex-app-server] unable to read resume preview for ${item.id}: ${error.message}`);
|
|
782
1022
|
}
|
|
@@ -818,20 +1058,24 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
818
1058
|
const threads = (result?.data || []).filter(item => !item.parentThreadId);
|
|
819
1059
|
const histories = await Promise.all(threads.map(async item => {
|
|
820
1060
|
try {
|
|
821
|
-
const history = await this.
|
|
1061
|
+
const history = await this.readRecentThread(item, 200);
|
|
822
1062
|
const fallbackTimestamp = toTimestampMs(item.updatedAt || item.createdAt);
|
|
823
|
-
return
|
|
824
|
-
|
|
1063
|
+
return {
|
|
1064
|
+
prompts: userPromptsFromThread(history || { id: item.id, turns: [] }, fallbackTimestamp)
|
|
1065
|
+
.map(prompt => ({ ...prompt, threadId: prompt.threadId || item.id })),
|
|
1066
|
+
capped: Boolean(history.historyNextCursor)
|
|
1067
|
+
};
|
|
825
1068
|
} catch (error) {
|
|
826
1069
|
this.logger.debugInfo?.(`[codex-app-server] unable to read prompt history for ${item.id}: ${error.message}`);
|
|
827
|
-
return [];
|
|
1070
|
+
return { prompts: [], capped: false };
|
|
828
1071
|
}
|
|
829
1072
|
}));
|
|
830
|
-
prompts.push(...histories.
|
|
1073
|
+
prompts.push(...histories.flatMap(history => history.prompts));
|
|
1074
|
+
if (histories.some(history => history.capped)) capped = true;
|
|
831
1075
|
cursor = result?.nextCursor || null;
|
|
832
1076
|
pageCount += 1;
|
|
833
1077
|
if (prompts.length >= 200 || pageCount >= 5) {
|
|
834
|
-
capped = Boolean(cursor) || prompts.length > 200;
|
|
1078
|
+
capped = capped || Boolean(cursor) || prompts.length > 200;
|
|
835
1079
|
break;
|
|
836
1080
|
}
|
|
837
1081
|
} while (cursor);
|
|
@@ -952,11 +1196,35 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
952
1196
|
return this.getControlState();
|
|
953
1197
|
}
|
|
954
1198
|
|
|
1199
|
+
async resumeThreadAfterProcessRestart() {
|
|
1200
|
+
if (!this.threadId || !this.needsThreadResume) return false;
|
|
1201
|
+
const result = await this.request('thread/resume', this.threadResumeParams(this.threadId), {
|
|
1202
|
+
timeoutMs: RESUME_REQUEST_TIMEOUT_MS,
|
|
1203
|
+
fatalOnTimeout: true
|
|
1204
|
+
});
|
|
1205
|
+
this.needsThreadResume = false;
|
|
1206
|
+
this.model = result.model || result.thread?.model || this.model;
|
|
1207
|
+
this.effort = result.reasoningEffort || result.thread?.reasoningEffort || this.effort;
|
|
1208
|
+
this.effectivePermissionMode = result.approvalPolicy || this.effectivePermissionMode;
|
|
1209
|
+
this.effectiveSandboxMode = sandboxModeFromPolicy(result.sandbox) || this.effectiveSandboxMode;
|
|
1210
|
+
return true;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
threadResumeParams(threadId) {
|
|
1214
|
+
const params = { threadId, cwd: this.workingDir };
|
|
1215
|
+
if (this.permissionMode) params.approvalPolicy = this.permissionMode;
|
|
1216
|
+
if (this.sandboxMode) params.sandbox = this.sandboxMode;
|
|
1217
|
+
if (this.hasModelOverride && this.model) params.model = this.model;
|
|
1218
|
+
if (this.hasEffortOverride && this.effort) params.config = { model_reasoning_effort: this.effort };
|
|
1219
|
+
return params;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
955
1222
|
async sendUserMessage(text, attachments = [], skills = []) {
|
|
956
1223
|
const prompt = String(text || '').trim();
|
|
957
1224
|
const images = (Array.isArray(attachments) ? attachments : [])
|
|
958
1225
|
.filter(item => item && typeof item.path === 'string' && item.path);
|
|
959
|
-
if ((!prompt && images.length === 0) || this.presentation !== 'structured'
|
|
1226
|
+
if ((!prompt && images.length === 0) || this.presentation !== 'structured'
|
|
1227
|
+
|| this.status !== 'idle' || this.aborting || this.resuming) return false;
|
|
960
1228
|
this.hasUnreadCompletion = false;
|
|
961
1229
|
this.promptHistoryCache = null;
|
|
962
1230
|
this.append({
|
|
@@ -967,8 +1235,10 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
967
1235
|
name: String(item?.name || ''), path: String(item?.path || '')
|
|
968
1236
|
})).filter(item => item.name && item.path)
|
|
969
1237
|
});
|
|
1238
|
+
this.setStatus('running');
|
|
970
1239
|
try {
|
|
971
1240
|
await this.ensureProcess();
|
|
1241
|
+
await this.resumeThreadAfterProcessRestart();
|
|
972
1242
|
if (!this.threadId) {
|
|
973
1243
|
const params = { cwd: this.workingDir };
|
|
974
1244
|
if (this.hasModelOverride) params.model = this.model;
|
|
@@ -976,13 +1246,13 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
976
1246
|
if (this.sandboxMode) params.sandbox = this.sandboxMode;
|
|
977
1247
|
const started = await this.request('thread/start', params);
|
|
978
1248
|
this.threadId = started.thread?.id;
|
|
1249
|
+
this.needsThreadResume = false;
|
|
979
1250
|
this.model = started.model || this.model;
|
|
980
1251
|
this.effort = started.reasoningEffort || this.effort;
|
|
981
1252
|
this.effectivePermissionMode = started.approvalPolicy || this.effectivePermissionMode;
|
|
982
1253
|
this.effectiveSandboxMode = sandboxModeFromPolicy(started.sandbox) || this.effectiveSandboxMode;
|
|
983
1254
|
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
984
1255
|
}
|
|
985
|
-
this.setStatus('running');
|
|
986
1256
|
const input = [];
|
|
987
1257
|
input.push(...await this.resolveSkillInputs(skills));
|
|
988
1258
|
if (prompt) input.push({ type: 'text', text: prompt });
|
|
@@ -1008,7 +1278,8 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1008
1278
|
}
|
|
1009
1279
|
|
|
1010
1280
|
async compactContext() {
|
|
1011
|
-
if (!this.threadId || this.presentation !== 'structured' || this.status !== 'idle'
|
|
1281
|
+
if (!this.threadId || this.presentation !== 'structured' || this.status !== 'idle'
|
|
1282
|
+
|| this.aborting || this.resuming) return false;
|
|
1012
1283
|
await this.ensureProcess();
|
|
1013
1284
|
this.compacting = true;
|
|
1014
1285
|
this.setStatus('running');
|
|
@@ -1059,7 +1330,17 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1059
1330
|
}
|
|
1060
1331
|
|
|
1061
1332
|
abort(reason = 'Aborted by user') {
|
|
1062
|
-
if (this.presentation !== 'structured' || this.status === 'idle') return false;
|
|
1333
|
+
if (this.presentation !== 'structured' || (this.status === 'idle' && !this.resuming)) return false;
|
|
1334
|
+
if (this.aborting) return true;
|
|
1335
|
+
if (this.resuming) {
|
|
1336
|
+
this.aborting = true;
|
|
1337
|
+
this.needsThreadResume = Boolean(this.threadId || this.resumeTarget);
|
|
1338
|
+
this.emitControlState();
|
|
1339
|
+
this.append({ kind: 'event', level: 'info', text: reason });
|
|
1340
|
+
const error = new Error('Codex resume aborted by user');
|
|
1341
|
+
void this.disconnectProcess(true, error);
|
|
1342
|
+
return true;
|
|
1343
|
+
}
|
|
1063
1344
|
for (const pending of this.pendingPermissions.values()) {
|
|
1064
1345
|
const response = pending.method === 'item/permissions/requestApproval'
|
|
1065
1346
|
? { permissions: {}, scope: 'turn' }
|
|
@@ -1077,40 +1358,108 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1077
1358
|
&& !targets.some(target => target.threadId === this.threadId && target.turnId === this.currentTurnId)) {
|
|
1078
1359
|
targets.push({ threadId: this.threadId, turnId: this.currentTurnId });
|
|
1079
1360
|
}
|
|
1361
|
+
this.aborting = true;
|
|
1362
|
+
this.abortTargets = new Map(targets.map(target => [turnKey(target.threadId, target.turnId), target]));
|
|
1363
|
+
this.emitControlState();
|
|
1080
1364
|
for (const target of targets) {
|
|
1081
1365
|
this.request('turn/interrupt', target).catch(error => {
|
|
1082
1366
|
this.logger.debugInfo?.(`[codex-app-server] turn/interrupt failed for ${target.threadId}/${target.turnId}: ${error.message}`);
|
|
1083
1367
|
});
|
|
1084
1368
|
}
|
|
1085
1369
|
this.append({ kind: 'event', level: 'info', text: reason });
|
|
1370
|
+
this.abortTimer = setTimeout(() => this.forceAbortAfterTimeout(), this.abortGraceMs);
|
|
1371
|
+
this.abortTimer.unref?.();
|
|
1086
1372
|
return true;
|
|
1087
1373
|
}
|
|
1088
1374
|
|
|
1089
|
-
|
|
1375
|
+
forceAbortAfterTimeout() {
|
|
1376
|
+
if (!this.aborting) return false;
|
|
1377
|
+
const targets = Array.from(this.abortTargets.values());
|
|
1378
|
+
const completedAtMs = Date.now();
|
|
1379
|
+
const timeoutSeconds = Math.max(1, Math.round(this.abortGraceMs / 1000));
|
|
1380
|
+
this.append({ kind: 'event', level: 'warning',
|
|
1381
|
+
text: `Codex did not stop within ${timeoutSeconds} seconds. Stopping its app-server.` });
|
|
1382
|
+
|
|
1383
|
+
for (const target of targets) {
|
|
1384
|
+
const tracked = this.threadTurns.get(target.threadId);
|
|
1385
|
+
const startedAtMs = Number(tracked?.startedAt || 0)
|
|
1386
|
+
|| (target.threadId === this.threadId ? Number(this.currentTurnStartedAt || 0) : 0);
|
|
1387
|
+
const existingTurnEnd = this.messages.find(item => item.kind === 'turn-end'
|
|
1388
|
+
&& item.threadId === target.threadId && item.turnId === target.turnId);
|
|
1389
|
+
if (!existingTurnEnd) {
|
|
1390
|
+
this.append({ kind: 'turn-end', threadId: target.threadId, turnId: target.turnId,
|
|
1391
|
+
status: 'cancelled', durationMs: startedAtMs ? Math.max(0, completedAtMs - startedAtMs) : null,
|
|
1392
|
+
createdAt: completedAtMs });
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
for (const item of this.messages.filter(message => message.kind === 'tool'
|
|
1396
|
+
&& ['running', 'inProgress'].includes(message.toolStatus))) {
|
|
1397
|
+
const startedAtMs = Number(item.startedAtMs || item.createdAt || 0);
|
|
1398
|
+
this.patch(item.id, { toolStatus: 'cancelled', completedAtMs,
|
|
1399
|
+
...(startedAtMs ? { durationMs: Math.max(1, completedAtMs - startedAtMs) } : {}) });
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
this.needsThreadResume = Boolean(this.threadId);
|
|
1403
|
+
this.currentTurnId = null;
|
|
1404
|
+
this.currentTurnStartedAt = null;
|
|
1405
|
+
this.threadTurns.clear();
|
|
1406
|
+
this.pendingPermissions.clear();
|
|
1407
|
+
this.compacting = false;
|
|
1408
|
+
this.hasUnreadCompletion = true;
|
|
1409
|
+
this.clearAbortState(false);
|
|
1410
|
+
void this.disconnectProcess(true);
|
|
1411
|
+
if (this.status !== 'idle') this.setStatus('idle');
|
|
1412
|
+
else this.emitControlState();
|
|
1413
|
+
this.append({ kind: 'event', level: 'info',
|
|
1414
|
+
text: 'Codex app-server stopped. It will restart before the next message.' });
|
|
1415
|
+
return true;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
resume(threadId = null) {
|
|
1090
1419
|
const target = String(threadId || this.threadId || '').trim();
|
|
1091
|
-
if (!target || this.presentation !== 'structured' || this.status !== 'idle') return false;
|
|
1420
|
+
if (!target || this.presentation !== 'structured' || this.status !== 'idle' || this.aborting) return false;
|
|
1421
|
+
if (this.resumePromise) return target === this.resumeTarget ? this.resumePromise : false;
|
|
1422
|
+
this.resuming = true;
|
|
1423
|
+
this.resumeTarget = target;
|
|
1424
|
+
this.emitControlState();
|
|
1425
|
+
let tracked;
|
|
1426
|
+
tracked = this.performResume(target).finally(() => {
|
|
1427
|
+
if (this.resumePromise !== tracked) return;
|
|
1428
|
+
this.resumePromise = null;
|
|
1429
|
+
this.resumeTarget = null;
|
|
1430
|
+
this.resuming = false;
|
|
1431
|
+
this.clearAbortState(false);
|
|
1432
|
+
this.emitControlState();
|
|
1433
|
+
});
|
|
1434
|
+
this.resumePromise = tracked;
|
|
1435
|
+
return tracked;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
async performResume(target) {
|
|
1092
1439
|
await this.ensureProcess();
|
|
1093
1440
|
const selectedModel = this.model;
|
|
1094
1441
|
const selectedEffort = this.effort;
|
|
1095
1442
|
const resumeWithModelOverride = Boolean(this.hasModelOverride && selectedModel);
|
|
1096
1443
|
const resumeWithEffortOverride = Boolean(this.hasEffortOverride && selectedEffort);
|
|
1097
|
-
const params =
|
|
1098
|
-
if (this.permissionMode) params.approvalPolicy = this.permissionMode;
|
|
1099
|
-
if (this.sandboxMode) params.sandbox = this.sandboxMode;
|
|
1100
|
-
if (resumeWithModelOverride) params.model = selectedModel;
|
|
1101
|
-
if (resumeWithEffortOverride) params.config = { model_reasoning_effort: selectedEffort };
|
|
1444
|
+
const params = this.threadResumeParams(target);
|
|
1102
1445
|
this.deferredWarnings = [];
|
|
1103
1446
|
try {
|
|
1104
|
-
const result = await this.request('thread/resume', params
|
|
1447
|
+
const result = await this.request('thread/resume', params, {
|
|
1448
|
+
timeoutMs: RESUME_REQUEST_TIMEOUT_MS,
|
|
1449
|
+
fatalOnTimeout: true
|
|
1450
|
+
});
|
|
1105
1451
|
this.threadId = result.thread?.id || target;
|
|
1452
|
+
this.needsThreadResume = false;
|
|
1106
1453
|
this.hasModelOverride = resumeWithModelOverride;
|
|
1107
1454
|
this.hasEffortOverride = resumeWithEffortOverride;
|
|
1108
1455
|
this.model = result.model || result.thread?.model || selectedModel;
|
|
1109
1456
|
this.effort = result.reasoningEffort || result.thread?.reasoningEffort || selectedEffort;
|
|
1110
1457
|
this.effectivePermissionMode = result.approvalPolicy || this.effectivePermissionMode;
|
|
1111
1458
|
this.effectiveSandboxMode = sandboxModeFromPolicy(result.sandbox) || this.effectiveSandboxMode;
|
|
1112
|
-
const history = await this.
|
|
1113
|
-
|
|
1459
|
+
const history = await this.readRecentThread({ ...(result.thread || {}), id: this.threadId },
|
|
1460
|
+
RESUME_HISTORY_TURN_LIMIT, { timeoutMs: RESUME_REQUEST_TIMEOUT_MS, fatalOnTimeout: true });
|
|
1461
|
+
const historyNote = history.historyNextCursor ? ' · showing the latest 50 turns' : '';
|
|
1462
|
+
this.restoreThreadHistory(history, `Resumed Codex thread ${this.threadId}${historyNote}`, {
|
|
1114
1463
|
preserveModel: resumeWithModelOverride,
|
|
1115
1464
|
preserveEffort: resumeWithEffortOverride
|
|
1116
1465
|
});
|
|
@@ -1120,9 +1469,14 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1120
1469
|
this.promptHistoryCache = null;
|
|
1121
1470
|
return true;
|
|
1122
1471
|
} catch (error) {
|
|
1472
|
+
const aborted = this.aborting || /resume aborted/i.test(error.message);
|
|
1123
1473
|
const warnings = this.deferredWarnings || [];
|
|
1124
1474
|
this.deferredWarnings = null;
|
|
1125
1475
|
for (const warning of warnings) this.append(warning);
|
|
1476
|
+
this.needsThreadResume = Boolean(this.threadId || target);
|
|
1477
|
+
await this.disconnectProcess(true, error);
|
|
1478
|
+
this.append({ kind: 'event', level: aborted ? 'info' : 'error',
|
|
1479
|
+
text: aborted ? 'Codex conversation recovery stopped.' : `Unable to resume Codex conversation: ${error.message}` });
|
|
1126
1480
|
throw error;
|
|
1127
1481
|
}
|
|
1128
1482
|
}
|
|
@@ -1138,24 +1492,24 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1138
1492
|
this.providerItemContexts.clear();
|
|
1139
1493
|
try {
|
|
1140
1494
|
for (const turn of thread?.turns || []) {
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1495
|
+
const status = turn.status === 'failed' ? 'failed' : turn.status === 'interrupted' ? 'cancelled' : 'completed';
|
|
1496
|
+
const startedAt = Number(turn.startedAt || turn.createdAt || 0);
|
|
1497
|
+
const completedAt = Number(turn.completedAt || turn.updatedAt || 0);
|
|
1498
|
+
const toMilliseconds = value => value > 0 && value < 100000000000 ? value * 1000 : value;
|
|
1499
|
+
const startedAtMs = toMilliseconds(startedAt);
|
|
1500
|
+
const completedAtMs = toMilliseconds(completedAt);
|
|
1501
|
+
this.append({ kind: 'turn-start', turnId: turn.id, ...(startedAtMs ? { createdAt: startedAtMs } : {}) });
|
|
1502
|
+
for (const item of turn.items || []) {
|
|
1503
|
+
this.applyProviderItem({ ...item, turnId: turn.id }, status === 'failed' ? 'failed' : 'completed', {
|
|
1504
|
+
threadId: this.threadId,
|
|
1505
|
+
startedAtMs,
|
|
1506
|
+
completedAtMs
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
const durationMs = Number(turn.durationMs || turn.duration_ms || 0)
|
|
1510
|
+
|| (startedAtMs && completedAtMs && completedAtMs >= startedAtMs ? completedAtMs - startedAtMs : null);
|
|
1511
|
+
this.append({ kind: 'turn-end', turnId: turn.id, status, durationMs,
|
|
1512
|
+
...(completedAtMs ? { createdAt: completedAtMs } : {}) });
|
|
1159
1513
|
}
|
|
1160
1514
|
if (eventText) this.append({ kind: 'event', level: 'info', text: eventText });
|
|
1161
1515
|
} finally {
|
|
@@ -1167,7 +1521,8 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1167
1521
|
|
|
1168
1522
|
async forkFrom(threadId) {
|
|
1169
1523
|
const sourceThreadId = String(threadId || '').trim();
|
|
1170
|
-
if (!sourceThreadId || this.presentation !== 'structured' || this.status !== 'idle'
|
|
1524
|
+
if (!sourceThreadId || this.presentation !== 'structured' || this.status !== 'idle'
|
|
1525
|
+
|| this.aborting || this.resuming) return false;
|
|
1171
1526
|
await this.ensureProcess();
|
|
1172
1527
|
const params = { threadId: sourceThreadId, cwd: this.workingDir, ephemeral: false, threadSource: null };
|
|
1173
1528
|
if (this.hasModelOverride) params.model = this.model;
|
|
@@ -1177,6 +1532,7 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1177
1532
|
const forkedThread = result?.thread;
|
|
1178
1533
|
if (!forkedThread?.id) throw new Error('Codex did not return a forked thread');
|
|
1179
1534
|
this.threadId = forkedThread.id;
|
|
1535
|
+
this.needsThreadResume = false;
|
|
1180
1536
|
this.model = result.model || forkedThread.model || this.model;
|
|
1181
1537
|
this.effort = result.reasoningEffort || forkedThread.reasoningEffort || forkedThread.reasoning_effort || this.effort;
|
|
1182
1538
|
this.effectivePermissionMode = result.approvalPolicy || this.effectivePermissionMode;
|
|
@@ -1187,7 +1543,8 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1187
1543
|
}
|
|
1188
1544
|
|
|
1189
1545
|
async switchToTerminal() {
|
|
1190
|
-
if (this.presentation !== 'structured' || this.status !== 'idle' ||
|
|
1546
|
+
if (this.presentation !== 'structured' || this.status !== 'idle' || this.aborting
|
|
1547
|
+
|| this.resuming || !this.threadId) throw new Error('Codex must be idle before switching to Terminal.');
|
|
1191
1548
|
await this.disconnectProcess();
|
|
1192
1549
|
this.terminalOutput = '';
|
|
1193
1550
|
const terminal = new PTYManager(this.tool, this.workingDir, { append() {} }, { silent: true });
|
|
@@ -1218,19 +1575,53 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1218
1575
|
return this.resume();
|
|
1219
1576
|
}
|
|
1220
1577
|
|
|
1221
|
-
async disconnectProcess() {
|
|
1222
|
-
if (!this.process) return;
|
|
1578
|
+
async disconnectProcess(force = false, reason = null) {
|
|
1579
|
+
if (!this.process) return this.processShutdown || undefined;
|
|
1223
1580
|
const child = this.process;
|
|
1224
1581
|
this.process = null;
|
|
1225
1582
|
this.processReady = null;
|
|
1226
|
-
|
|
1583
|
+
const socket = this.rpcSocket;
|
|
1584
|
+
this.rpcSocket = null;
|
|
1585
|
+
socket?.terminate();
|
|
1586
|
+
const disconnectError = reason instanceof Error ? reason : new Error('Codex app-server disconnected');
|
|
1587
|
+
for (const request of this.pendingRequests.values()) { clearTimeout(request.timer); request.reject(disconnectError); }
|
|
1227
1588
|
this.pendingRequests.clear();
|
|
1228
|
-
child.
|
|
1589
|
+
const shutdown = typeof child.once === 'function'
|
|
1590
|
+
? new Promise(resolve => {
|
|
1591
|
+
let timer;
|
|
1592
|
+
const finish = () => { clearTimeout(timer); resolve(); };
|
|
1593
|
+
child.once('exit', finish);
|
|
1594
|
+
timer = setTimeout(() => {
|
|
1595
|
+
this.logger.debugInfo?.('[codex-app-server] process did not exit within 2 seconds');
|
|
1596
|
+
finish();
|
|
1597
|
+
}, PROCESS_SHUTDOWN_TIMEOUT_MS);
|
|
1598
|
+
})
|
|
1599
|
+
: Promise.resolve();
|
|
1600
|
+
this.processShutdown = shutdown;
|
|
1601
|
+
if (force) this.forceKillProcessTree(child);
|
|
1602
|
+
else child.kill();
|
|
1603
|
+
try {
|
|
1604
|
+
await shutdown;
|
|
1605
|
+
} finally {
|
|
1606
|
+
if (this.processShutdown === shutdown) this.processShutdown = null;
|
|
1607
|
+
}
|
|
1229
1608
|
}
|
|
1230
1609
|
|
|
1231
1610
|
markCompletionRead() { this.hasUnreadCompletion = false; this.completionReadInputSeq = this.inputSeq; }
|
|
1232
|
-
kill() {
|
|
1611
|
+
async kill() {
|
|
1612
|
+
this.running = false;
|
|
1613
|
+
this.clearAbortState(false);
|
|
1614
|
+
this.resuming = false;
|
|
1615
|
+
this.resumeTarget = null;
|
|
1616
|
+
this.terminalSession?.kill();
|
|
1617
|
+
this.terminalSession = null;
|
|
1618
|
+
await this.disconnectProcess(true);
|
|
1619
|
+
this.emit('exit');
|
|
1620
|
+
}
|
|
1233
1621
|
}
|
|
1234
1622
|
|
|
1235
1623
|
module.exports = CodexStructuredSession;
|
|
1236
1624
|
module.exports.appServerSpawnOptions = appServerSpawnOptions;
|
|
1625
|
+
module.exports.forceKillProcessTree = forceKillProcessTree;
|
|
1626
|
+
module.exports.reserveLoopbackPort = reserveLoopbackPort;
|
|
1627
|
+
module.exports.connectAppServerWebSocket = connectAppServerWebSocket;
|
package/lib/commands/web.js
CHANGED
|
@@ -276,8 +276,8 @@ async function webCommand(options) {
|
|
|
276
276
|
});
|
|
277
277
|
|
|
278
278
|
// API: Delete/Kill session
|
|
279
|
-
app.delete('/api/sessions/:id', (req, res) => {
|
|
280
|
-
sessionManager.kill(req.params.id);
|
|
279
|
+
app.delete('/api/sessions/:id', async (req, res) => {
|
|
280
|
+
await sessionManager.kill(req.params.id);
|
|
281
281
|
res.json({ success: true });
|
|
282
282
|
});
|
|
283
283
|
|
|
@@ -409,7 +409,7 @@ class SessionManager extends EventEmitter {
|
|
|
409
409
|
async forkCodex(id, threadId) {
|
|
410
410
|
const session = this.get(id);
|
|
411
411
|
if (!session || session.kind !== 'codex-structured') return null;
|
|
412
|
-
if (session.presentation !== 'structured' || session.status !== 'idle') {
|
|
412
|
+
if (session.presentation !== 'structured' || session.status !== 'idle' || session.resuming) {
|
|
413
413
|
const error = new Error('Codex must be idle in chat mode before forking');
|
|
414
414
|
error.statusCode = 409;
|
|
415
415
|
throw error;
|
|
@@ -610,7 +610,7 @@ class SessionManager extends EventEmitter {
|
|
|
610
610
|
return true;
|
|
611
611
|
}
|
|
612
612
|
|
|
613
|
-
kill(id) {
|
|
613
|
+
async kill(id) {
|
|
614
614
|
const session = this.get(id);
|
|
615
615
|
if (!session) return false;
|
|
616
616
|
clearTimeout(session.completionTimer);
|
|
@@ -620,7 +620,7 @@ class SessionManager extends EventEmitter {
|
|
|
620
620
|
this.logSessionDiagnostics('session-deleted', session, {}, { compact: true });
|
|
621
621
|
this.disposeSessionHistory(session);
|
|
622
622
|
if (['claude-structured', 'codex-structured'].includes(session.kind)) {
|
|
623
|
-
session.ptyManager.kill();
|
|
623
|
+
await session.ptyManager.kill();
|
|
624
624
|
return true;
|
|
625
625
|
}
|
|
626
626
|
session.ptyManager.kill();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { spawn } = require('child_process');
|
|
2
|
+
const { chmodSync, statSync } = require('fs');
|
|
2
3
|
|
|
3
4
|
const MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
4
5
|
const DEFAULT_TIMEOUT_MS = 45000;
|
|
@@ -25,6 +26,22 @@ function resolveCcusageBinary(platform = process.platform, arch = process.arch)
|
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
function ensureCcusageBinaryExecutable(binaryPath, options = {}) {
|
|
30
|
+
const platform = options.platform || process.platform;
|
|
31
|
+
if (platform === 'win32') return binaryPath;
|
|
32
|
+
|
|
33
|
+
const statPath = options.statPath || statSync;
|
|
34
|
+
const chmodPath = options.chmodPath || chmodSync;
|
|
35
|
+
try {
|
|
36
|
+
// ccusage's platform packages can be installed without execute bits. Its JS
|
|
37
|
+
// wrapper repairs them too, but this runner intentionally spawns the native binary.
|
|
38
|
+
if ((statPath(binaryPath).mode & 0o111) === 0) chmodPath(binaryPath, 0o755);
|
|
39
|
+
return binaryPath;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw new Error(`ccusage native binary is not executable: ${error.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
28
45
|
function reportArgs(timezone) {
|
|
29
46
|
return [
|
|
30
47
|
'daily',
|
|
@@ -38,7 +55,8 @@ function reportArgs(timezone) {
|
|
|
38
55
|
|
|
39
56
|
class CcusageRunner {
|
|
40
57
|
constructor(options = {}) {
|
|
41
|
-
this.binaryPath = options.binaryPath
|
|
58
|
+
this.binaryPath = options.binaryPath
|
|
59
|
+
|| ensureCcusageBinaryExecutable(resolveCcusageBinary());
|
|
42
60
|
this.timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
43
61
|
this.spawnProcess = options.spawnProcess || spawn;
|
|
44
62
|
}
|
|
@@ -102,4 +120,9 @@ class CcusageRunner {
|
|
|
102
120
|
}
|
|
103
121
|
}
|
|
104
122
|
|
|
105
|
-
module.exports = {
|
|
123
|
+
module.exports = {
|
|
124
|
+
CcusageRunner,
|
|
125
|
+
ensureCcusageBinaryExecutable,
|
|
126
|
+
reportArgs,
|
|
127
|
+
resolveCcusageBinary
|
|
128
|
+
};
|
package/lib/web/codex.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
function codexText(text) { return escapeHtml(text || '').replace(/\n/g, '<br>'); }
|
|
2
|
+
function codexReadyForInput() {
|
|
3
|
+
return codexState.presentation === 'structured' && codexState.status === 'idle'
|
|
4
|
+
&& !codexState.aborting && !codexState.resuming && !codexResumeInFlight;
|
|
5
|
+
}
|
|
2
6
|
function codexJson(value) {
|
|
3
7
|
if (typeof value === 'string') return value;
|
|
4
8
|
try { return JSON.stringify(value || {}, null, 2); } catch (_) { return String(value || ''); }
|
|
@@ -324,7 +328,7 @@
|
|
|
324
328
|
i += 1;
|
|
325
329
|
}
|
|
326
330
|
for (const request of codexPendingPermissions) if (!usedPermissions.has(request.id)) parts.push(renderCodexPermission(request));
|
|
327
|
-
const skillBubble = selectedCodexSkill &&
|
|
331
|
+
const skillBubble = selectedCodexSkill && codexReadyForInput()
|
|
328
332
|
? `<div class="codex-skill-bubble" role="status" aria-label="Selected skill: ${escapeHtml(selectedCodexSkill.name)}"><span class="codex-skill-bubble-name">Skill · ${escapeHtml(selectedCodexSkill.name)}</span><button type="button" class="codex-skill-bubble-close" onclick="clearCodexSkillSelection()" title="Remove selected skill" aria-label="Remove selected skill">×</button></div>`
|
|
329
333
|
: '';
|
|
330
334
|
const working = `<div class="codex-working-indicator" role="status" aria-label="Codex is working" title="Codex is working"${codexState.status === 'running' ? '' : ' style="display:none"'}></div>`;
|
|
@@ -443,13 +447,18 @@
|
|
|
443
447
|
const modelButton = document.getElementById('codex-model-btn');
|
|
444
448
|
if (modelButton) modelButton.textContent = 'Model';
|
|
445
449
|
const abort = document.getElementById('codex-abort-btn');
|
|
446
|
-
if (abort)
|
|
450
|
+
if (abort) {
|
|
451
|
+
abort.disabled = !codexState.canAbort;
|
|
452
|
+
abort.textContent = codexState.aborting ? 'Aborting…' : 'Abort';
|
|
453
|
+
}
|
|
447
454
|
const compact = document.getElementById('codex-compact-btn');
|
|
448
455
|
if (compact) { compact.disabled = !codexState.canCompact; compact.textContent = codexState.compacting ? 'Compacting' : 'Compact'; }
|
|
449
456
|
const skills = document.getElementById('codex-skills-btn');
|
|
450
|
-
if (skills) { skills.disabled = !(
|
|
457
|
+
if (skills) { skills.disabled = !codexReadyForInput(); skills.classList.toggle('primary', Boolean(selectedCodexSkill)); }
|
|
458
|
+
const resume = document.getElementById('codex-resume-btn');
|
|
459
|
+
if (resume) resume.disabled = !codexReadyForInput();
|
|
451
460
|
const fork = document.getElementById('codex-fork-btn');
|
|
452
|
-
if (fork) fork.disabled = !(
|
|
461
|
+
if (fork) fork.disabled = !codexReadyForInput();
|
|
453
462
|
const terminal = document.getElementById('codex-terminal-switch');
|
|
454
463
|
if (terminal) { terminal.textContent = codexState.presentation === 'terminal' ? 'CHAT' : 'TERM'; terminal.disabled = codexState.presentation === 'structured' && !codexState.canSwitchToTerminal; terminal.title = codexState.presentation === 'terminal' ? 'Return to Codex chat' : 'Switch to Codex terminal'; }
|
|
455
464
|
renderCodexStateBar();
|
|
@@ -464,6 +473,8 @@
|
|
|
464
473
|
const pending = Number(codexState.pendingPermissionCount || codexPendingPermissions.filter(item => item.status === 'pending').length) || 0;
|
|
465
474
|
const subagents = Number(codexState.activeSubagentCount || 0) || 0;
|
|
466
475
|
const parts = [];
|
|
476
|
+
if (codexState.aborting) parts.push('<span class="claude-state-pill warn">Stopping Codex…</span>');
|
|
477
|
+
else if (codexState.resuming || codexResumeInFlight) parts.push('<span class="claude-state-pill">Resuming conversation…</span>');
|
|
467
478
|
if (pending || codexState.status === 'waiting_approval') parts.push(`<button type="button" class="claude-state-pill warn codex-approval-jump" onclick="jumpToCodexApproval()" title="Jump to pending approval" aria-label="Jump to pending approval">${pending || 1} approval${pending === 1 ? '' : 's'}<span aria-hidden="true">↓</span></button>`);
|
|
468
479
|
if (subagents) parts.push(`<span class="claude-state-pill">${subagents} subagent${subagents === 1 ? '' : 's'} running</span>`);
|
|
469
480
|
el.innerHTML = parts.join('');
|
|
@@ -589,7 +600,12 @@
|
|
|
589
600
|
applyCodexState({ permissionMode, sandboxMode });
|
|
590
601
|
sendCodexSettings({ permissionMode, sandboxMode });
|
|
591
602
|
}
|
|
592
|
-
function abortCodexSession() {
|
|
603
|
+
function abortCodexSession() {
|
|
604
|
+
if (!codexState.canAbort || codexState.aborting || currentSocket?.readyState !== 1) return false;
|
|
605
|
+
currentSocket.send(JSON.stringify({ type: 'codex-abort' }));
|
|
606
|
+
applyCodexState({ aborting: true, canAbort: false });
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
593
609
|
function requestCodexStatus() { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-status' })); }
|
|
594
610
|
function compactCodexContext() {
|
|
595
611
|
if (!codexState.canCompact || currentSocket?.readyState !== 1) return false;
|
|
@@ -599,7 +615,11 @@
|
|
|
599
615
|
}
|
|
600
616
|
function respondCodexPermission(id, decision) { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-permission', id, decision })); }
|
|
601
617
|
async function toggleCodexResumePanel() {
|
|
602
|
-
|
|
618
|
+
if (!codexReadyForInput()) return;
|
|
619
|
+
const panel = document.getElementById('codex-resume-panel');
|
|
620
|
+
const retryAfterFailure = panel.dataset.resumeError === 'true';
|
|
621
|
+
codexResumePanelOpen = retryAfterFailure ? true : !codexResumePanelOpen;
|
|
622
|
+
delete panel.dataset.resumeError;
|
|
603
623
|
codexModelPanelOpen = false;
|
|
604
624
|
codexForkPanelOpen = false;
|
|
605
625
|
codexPromptPanelOpen = false;
|
|
@@ -608,14 +628,13 @@
|
|
|
608
628
|
document.getElementById('codex-fork-panel').classList.remove('active');
|
|
609
629
|
document.getElementById('codex-prompt-panel').classList.remove('active');
|
|
610
630
|
document.getElementById('codex-skill-panel').classList.remove('active');
|
|
611
|
-
const panel = document.getElementById('codex-resume-panel');
|
|
612
631
|
panel.classList.toggle('active', codexResumePanelOpen);
|
|
613
632
|
updateTerminalControlsHeight();
|
|
614
633
|
if (!codexResumePanelOpen) return;
|
|
615
634
|
await loadCodexThreadPanel(panel, 'resume');
|
|
616
635
|
}
|
|
617
636
|
async function toggleCodexForkPanel() {
|
|
618
|
-
if (!(
|
|
637
|
+
if (!codexReadyForInput()) return;
|
|
619
638
|
codexForkPanelOpen = !codexForkPanelOpen;
|
|
620
639
|
codexModelPanelOpen = false;
|
|
621
640
|
codexResumePanelOpen = false;
|
|
@@ -632,6 +651,7 @@
|
|
|
632
651
|
await loadCodexThreadPanel(panel, 'fork');
|
|
633
652
|
}
|
|
634
653
|
async function loadCodexThreadPanel(panel, action) {
|
|
654
|
+
delete panel.dataset.resumeError;
|
|
635
655
|
panel.innerHTML = '<div class="claude-resume-meta" style="padding:12px;">Loading sessions...</div>';
|
|
636
656
|
try {
|
|
637
657
|
const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-resume-threads`, {}, 30000);
|
|
@@ -889,7 +909,7 @@
|
|
|
889
909
|
}
|
|
890
910
|
|
|
891
911
|
async function toggleCodexSkillPanel() {
|
|
892
|
-
if (!(
|
|
912
|
+
if (!codexReadyForInput()) return;
|
|
893
913
|
codexSkillPanelOpen = !codexSkillPanelOpen;
|
|
894
914
|
codexModelPanelOpen = false;
|
|
895
915
|
codexResumePanelOpen = false;
|
|
@@ -941,11 +961,37 @@
|
|
|
941
961
|
applyCodexState({});
|
|
942
962
|
}
|
|
943
963
|
async function selectCodexResumeThread(threadId) {
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
964
|
+
if (codexResumeInFlight || codexState.resuming) return;
|
|
965
|
+
codexResumeInFlight = true;
|
|
966
|
+
const panel = document.getElementById('codex-resume-panel');
|
|
967
|
+
panel.innerHTML = '<div class="claude-resume-meta" style="padding:12px;">Resuming conversation…</div>';
|
|
968
|
+
applyCodexState({ resuming: true, canAbort: true });
|
|
948
969
|
updateTerminalControlsHeight();
|
|
970
|
+
let failure = '';
|
|
971
|
+
try {
|
|
972
|
+
const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-resume`, {
|
|
973
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ threadId })
|
|
974
|
+
}, 70000);
|
|
975
|
+
const data = await res.json();
|
|
976
|
+
if (!res.ok || !data.success) throw new Error(data.error || 'Unable to resume Codex thread');
|
|
977
|
+
codexResumePanelOpen = false;
|
|
978
|
+
panel.classList.remove('active');
|
|
979
|
+
} catch (error) {
|
|
980
|
+
failure = /resume aborted/i.test(error.message || '')
|
|
981
|
+
? 'Conversation recovery stopped.'
|
|
982
|
+
: (error.message || 'Unable to resume Codex thread');
|
|
983
|
+
} finally {
|
|
984
|
+
codexResumeInFlight = false;
|
|
985
|
+
// 本地 in-flight 也参与控件状态,清除后必须主动重绘。
|
|
986
|
+
applyCodexState({ resuming: false, canAbort: false });
|
|
987
|
+
if (failure) {
|
|
988
|
+
codexResumePanelOpen = true;
|
|
989
|
+
panel.classList.add('active');
|
|
990
|
+
panel.dataset.resumeError = 'true';
|
|
991
|
+
panel.innerHTML = `<div class="claude-resume-meta" style="padding:12px;color:#ff6b61;">${escapeHtml(failure)}</div>`;
|
|
992
|
+
}
|
|
993
|
+
updateTerminalControlsHeight();
|
|
994
|
+
}
|
|
949
995
|
}
|
|
950
996
|
async function selectCodexForkThread(threadId) {
|
|
951
997
|
const panel = document.getElementById('codex-fork-panel');
|
package/lib/web/composer.js
CHANGED
|
@@ -206,6 +206,7 @@
|
|
|
206
206
|
return;
|
|
207
207
|
}
|
|
208
208
|
if ((val || readyImageAttachments.length) && isCodexSession() && codexState.presentation === 'structured') {
|
|
209
|
+
if (!codexReadyForInput()) return;
|
|
209
210
|
if (currentSocket && currentSocket.readyState === 1) {
|
|
210
211
|
currentSocket.send(JSON.stringify({
|
|
211
212
|
type: 'codex-input',
|
package/lib/web/core.js
CHANGED
|
@@ -30,12 +30,23 @@
|
|
|
30
30
|
let claudeResumeItemsLoaded = false;
|
|
31
31
|
let claudeRenderFrame = null;
|
|
32
32
|
let claudeApprovalJumpIndex = 0;
|
|
33
|
+
function createDefaultCodexState() {
|
|
34
|
+
return {
|
|
35
|
+
permissionMode: 'default', sandboxMode: 'default',
|
|
36
|
+
effectivePermissionMode: null, effectiveSandboxMode: null,
|
|
37
|
+
model: null, effort: null, status: 'idle', threadId: null,
|
|
38
|
+
presentation: 'structured', models: [], aborting: false, resuming: false,
|
|
39
|
+
canAbort: false, canCompact: false, compacting: false,
|
|
40
|
+
canSwitchToTerminal: false, canSwitchToStructured: false
|
|
41
|
+
};
|
|
42
|
+
}
|
|
33
43
|
let codexMessages = [];
|
|
34
44
|
let codexPendingPermissions = [];
|
|
35
|
-
let codexState =
|
|
45
|
+
let codexState = createDefaultCodexState();
|
|
36
46
|
let codexModelPanelOpen = false;
|
|
37
47
|
let codexModelCandidate = null;
|
|
38
48
|
let codexResumePanelOpen = false;
|
|
49
|
+
let codexResumeInFlight = false;
|
|
39
50
|
let codexForkPanelOpen = false;
|
|
40
51
|
let codexPromptPanelOpen = false;
|
|
41
52
|
let codexPromptItems = [];
|
|
@@ -226,14 +237,14 @@
|
|
|
226
237
|
: '';
|
|
227
238
|
html += `<div class="session-card">
|
|
228
239
|
<div class="session-info">
|
|
229
|
-
<h3>${escapeHtml(s.name)}${s.hasUnreadCompletion ? '<span class="completion-dot" title="Completed"></span>' : ''}${timerBadge} <button class="icon-btn" onclick="renameSession('${s.id}', decodePathValue('${encodedName}'), event)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg></button></h3>
|
|
240
|
+
<h3>${escapeHtml(s.name)}${s.hasUnreadCompletion ? '<span class="completion-dot" title="Completed"></span>' : ''}${timerBadge} <button type="button" class="icon-btn session-edit-btn" title="Rename session" aria-label="Rename session" onclick="renameSession('${s.id}', decodePathValue('${encodedName}'), event)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg></button></h3>
|
|
230
241
|
<p>${escapeHtml(s.tool)}</p>
|
|
231
242
|
<p>${new Date(s.startTime).toLocaleTimeString()}</p>
|
|
232
243
|
</div>
|
|
233
244
|
<div class="session-actions">
|
|
234
245
|
${renderServerChanSessionAction(s)}
|
|
235
246
|
<button class="btn-join" onclick="joinSession('${s.id}', decodePathValue('${encodedName}'), '${escapeHtml(s.toolKey || '')}')">Connect</button>
|
|
236
|
-
<button class="icon-btn btn-delete" onclick="deleteSession('${s.id}', event)"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>
|
|
247
|
+
<button type="button" class="icon-btn btn-delete session-delete-btn" title="Delete session" aria-label="Delete session" onclick="deleteSession('${s.id}', event)"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>
|
|
237
248
|
</div>
|
|
238
249
|
<div class="session-dir-row">
|
|
239
250
|
<button class="copy-dir-btn" title="Copy directory" onclick="copySessionDirectory(decodePathValue('${encodedDir}'), event)">
|
package/lib/web/session.js
CHANGED
|
@@ -155,7 +155,7 @@
|
|
|
155
155
|
document.getElementById('codex-prompt-panel').classList.remove('active');
|
|
156
156
|
document.getElementById('codex-skill-panel').classList.remove('active');
|
|
157
157
|
document.getElementById('codex-control-rail').scrollLeft = 0;
|
|
158
|
-
codexState =
|
|
158
|
+
codexState = createDefaultCodexState();
|
|
159
159
|
setClaudeModeEnabled(false);
|
|
160
160
|
applyCodexState(codexState);
|
|
161
161
|
installCodexLazyDetailHandler();
|
package/lib/web/styles.css
CHANGED
|
@@ -11,8 +11,7 @@
|
|
|
11
11
|
.header-action-btn.icon-only { width: 36px; padding: 0; }
|
|
12
12
|
.header-action-btn:active { background: #0062cc; transform: scale(.97); }
|
|
13
13
|
.btn-retry { background: #333; color: #fff; border: none; padding: 8px 16px; border-radius: 20px; margin-top: 10px; cursor: pointer; }
|
|
14
|
-
.session-card { background: var(--card-bg); border-radius: 12px; padding: 12px 16px 8px; margin-bottom: 12px; display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 10px; row-gap: 0; align-items: center;
|
|
15
|
-
.session-card:active { transform: scale(0.98); }
|
|
14
|
+
.session-card { background: var(--card-bg); border-radius: 12px; padding: 12px 16px 8px; margin-bottom: 12px; display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 10px; row-gap: 0; align-items: center; position: relative; }
|
|
16
15
|
.completion-dot { width: 9px; height: 9px; border-radius: 50%; background: #ff3b30; flex-shrink: 0; }
|
|
17
16
|
.session-info { flex: 1; min-width: 0; }
|
|
18
17
|
.session-info h3 { margin: 0 0 4px 0; font-size: 17px; display: flex; align-items: center; gap: 8px; }
|
|
@@ -29,6 +28,11 @@
|
|
|
29
28
|
.btn-join { background: rgba(255,255,255,0.1); border: none; color: var(--primary); padding: 8px 14px; border-radius: 18px; font-weight: 600; font-size: 14px; cursor: pointer; }
|
|
30
29
|
.icon-btn { color: var(--text-dim); background: none; border: none; padding: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
|
|
31
30
|
.icon-btn:active { color: var(--text); }
|
|
31
|
+
.session-edit-btn, .session-delete-btn { position: relative; }
|
|
32
|
+
.session-edit-btn::before, .session-delete-btn::before { content: ""; position: absolute; }
|
|
33
|
+
.session-edit-btn::before { inset: -9px; }
|
|
34
|
+
.session-delete-btn::before { inset: -8px; }
|
|
35
|
+
.session-edit-btn svg, .session-delete-btn svg { pointer-events: none; }
|
|
32
36
|
.btn-delete { color: #ff3b30; }
|
|
33
37
|
#modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); z-index: 10000; display: none; align-items: center; justify-content: center; padding: 20px; }
|
|
34
38
|
#tool-modal { background: var(--card-bg); width: 100%; max-width: 400px; border-radius: 16px; padding: 20px; box-shadow: 0 20px 40px rgba(0,0,0,0.4); }
|