glad-web 1.0.25 → 1.0.27
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 +174 -49
- package/lib/commands/web.js +8 -0
- package/lib/session/session-manager.js +39 -0
- package/lib/web/index.html +158 -38
- package/package.json +1 -1
|
@@ -63,6 +63,22 @@ function textFromInputItems(content) {
|
|
|
63
63
|
.join('\n');
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
function recentUserQuestions(thread, limit = 2) {
|
|
67
|
+
const questions = [];
|
|
68
|
+
const turns = Array.isArray(thread?.turns) ? thread.turns : [];
|
|
69
|
+
for (let turnIndex = turns.length - 1; turnIndex >= 0 && questions.length < limit; turnIndex -= 1) {
|
|
70
|
+
const items = Array.isArray(turns[turnIndex]?.items) ? turns[turnIndex].items : [];
|
|
71
|
+
for (let itemIndex = items.length - 1; itemIndex >= 0 && questions.length < limit; itemIndex -= 1) {
|
|
72
|
+
const item = items[itemIndex];
|
|
73
|
+
if (item?.type !== 'userMessage') continue;
|
|
74
|
+
const text = (textFromInputItems(item.content) || item.text || '').trim();
|
|
75
|
+
if (text) questions.push(text);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
while (questions.length < limit) questions.push('');
|
|
79
|
+
return questions;
|
|
80
|
+
}
|
|
81
|
+
|
|
66
82
|
function toolDetails(raw) {
|
|
67
83
|
if (raw.type === 'commandExecution') {
|
|
68
84
|
return {
|
|
@@ -98,13 +114,22 @@ function toolDetails(raw) {
|
|
|
98
114
|
};
|
|
99
115
|
}
|
|
100
116
|
if (raw.type === 'collabAgentToolCall') {
|
|
117
|
+
const receiverThreadIds = Array.isArray(raw.receiverThreadIds) ? raw.receiverThreadIds.filter(Boolean) : [];
|
|
118
|
+
const input = raw.arguments || raw.input || {
|
|
119
|
+
...(raw.prompt ? { prompt: raw.prompt } : {}),
|
|
120
|
+
...(receiverThreadIds.length ? { receiverThreadIds } : {}),
|
|
121
|
+
...(raw.agentsStates && Object.keys(raw.agentsStates).length ? { agentsStates: raw.agentsStates } : {})
|
|
122
|
+
};
|
|
101
123
|
return {
|
|
102
124
|
name: 'Agent',
|
|
103
125
|
title: raw.tool || raw.action || 'Subagent',
|
|
104
|
-
|
|
105
|
-
|
|
126
|
+
tool: raw.tool || raw.action || 'subagent',
|
|
127
|
+
input,
|
|
128
|
+
result: raw.error != null ? String(raw.error) : raw.result == null ? '' : safeJson(raw.result),
|
|
106
129
|
error: raw.error != null ? String(raw.error) : null,
|
|
107
|
-
subagentId: raw.receiverThreadId ||
|
|
130
|
+
subagentId: raw.receiverThreadId || receiverThreadIds[0] || raw.agentId || null,
|
|
131
|
+
subagentIds: receiverThreadIds,
|
|
132
|
+
agentsStates: raw.agentsStates || {}
|
|
108
133
|
};
|
|
109
134
|
}
|
|
110
135
|
return {
|
|
@@ -135,6 +160,7 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
135
160
|
this.threadId = options.resume || null;
|
|
136
161
|
this.currentTurnId = null;
|
|
137
162
|
this.currentTurnStartedAt = null;
|
|
163
|
+
this.threadTurns = new Map();
|
|
138
164
|
this.tokenUsage = null;
|
|
139
165
|
this.permissionMode = normalizePermissionMode(options.permissionMode);
|
|
140
166
|
this.sandboxMode = normalizeSandboxMode(options.sandboxMode);
|
|
@@ -186,6 +212,8 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
186
212
|
}
|
|
187
213
|
|
|
188
214
|
getControlState() {
|
|
215
|
+
const activeSubagentCount = Array.from(this.threadTurns.entries())
|
|
216
|
+
.filter(([threadId, turn]) => threadId !== this.threadId && turn?.status === 'running').length;
|
|
189
217
|
return { permissionMode: this.permissionMode || 'default', sandboxMode: this.sandboxMode || 'default',
|
|
190
218
|
effectivePermissionMode: this.effectivePermissionMode, effectiveSandboxMode: this.effectiveSandboxMode,
|
|
191
219
|
model: this.model, effort: this.effort,
|
|
@@ -193,7 +221,7 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
193
221
|
canAbort: this.presentation === 'structured' && this.status !== 'idle',
|
|
194
222
|
canSwitchToTerminal: this.presentation === 'structured' && this.status === 'idle' && Boolean(this.threadId),
|
|
195
223
|
canSwitchToStructured: this.presentation === 'terminal',
|
|
196
|
-
pendingPermissionCount: this.pendingPermissions.size, models: this.models };
|
|
224
|
+
pendingPermissionCount: this.pendingPermissions.size, activeSubagentCount, models: this.models };
|
|
197
225
|
}
|
|
198
226
|
|
|
199
227
|
getHistory() {
|
|
@@ -225,6 +253,7 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
225
253
|
}
|
|
226
254
|
emitEvent(event) { this.emit('event', event); }
|
|
227
255
|
setStatus(status) { if (this.status !== status) { this.status = status; this.emitEvent({ type: 'state', state: this.getControlState() }); } }
|
|
256
|
+
emitControlState() { this.emitEvent({ type: 'state', state: this.getControlState() }); }
|
|
228
257
|
recordPermission(request, status, decision) {
|
|
229
258
|
const completed = { ...request, status, decision };
|
|
230
259
|
this.completedPermissions = [...this.completedPermissions.filter(item => item.id !== request.id), completed].slice(-50);
|
|
@@ -349,44 +378,75 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
349
378
|
return;
|
|
350
379
|
}
|
|
351
380
|
if (method === 'turn/started') {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
381
|
+
const threadId = params.threadId || this.threadId;
|
|
382
|
+
const turnId = params.turn?.id || params.turnId || null;
|
|
383
|
+
const startedAt = Number(params.turn?.startedAt || 0);
|
|
384
|
+
const startedAtMs = startedAt > 0 && startedAt < 100000000000 ? startedAt * 1000 : startedAt || Date.now();
|
|
385
|
+
if (threadId && turnId) this.threadTurns.set(threadId, { turnId, startedAt: startedAtMs, status: 'running' });
|
|
386
|
+
if (!threadId || threadId === this.threadId) {
|
|
387
|
+
this.currentTurnId = turnId || this.currentTurnId;
|
|
388
|
+
this.currentTurnStartedAt = startedAtMs;
|
|
389
|
+
this.setStatus('running');
|
|
390
|
+
} else {
|
|
391
|
+
this.emitControlState();
|
|
392
|
+
}
|
|
393
|
+
this.append({ kind: 'turn-start', threadId, turnId, createdAt: startedAtMs });
|
|
356
394
|
return;
|
|
357
395
|
}
|
|
358
396
|
if (method === 'turn/completed') {
|
|
359
|
-
const
|
|
397
|
+
const threadId = params.threadId || this.threadId;
|
|
398
|
+
const trackedTurn = threadId ? this.threadTurns.get(threadId) : null;
|
|
399
|
+
const completedTurnId = params.turn?.id || params.turnId || trackedTurn?.turnId || this.currentTurnId;
|
|
360
400
|
const turnStatus = params.turn?.status === 'failed' || params.turn?.error ? 'failed'
|
|
361
401
|
: params.turn?.status === 'interrupted' ? 'cancelled' : 'completed';
|
|
362
|
-
|
|
363
|
-
|
|
402
|
+
const completedAt = Number(params.turn?.completedAt || 0);
|
|
403
|
+
const completedAtMs = completedAt > 0 && completedAt < 100000000000 ? completedAt * 1000 : completedAt || Date.now();
|
|
404
|
+
const startedAtMs = trackedTurn?.startedAt || ((!threadId || threadId === this.threadId) ? this.currentTurnStartedAt : null);
|
|
405
|
+
const durationMs = Number(params.turn?.durationMs || 0)
|
|
406
|
+
|| (startedAtMs ? Math.max(0, completedAtMs - startedAtMs) : null);
|
|
407
|
+
this.append({ kind: 'turn-end', threadId, turnId: completedTurnId, status: turnStatus,
|
|
408
|
+
durationMs, createdAt: completedAtMs });
|
|
409
|
+
const observedNow = Date.now();
|
|
410
|
+
const observedCompletedAtMs = Math.abs(observedNow - completedAtMs) < 5000
|
|
411
|
+
? Math.max(completedAtMs, observedNow) : completedAtMs;
|
|
364
412
|
for (const item of this.messages.filter(message => message.kind === 'tool'
|
|
365
413
|
&& message.turnId === completedTurnId && ['running', 'inProgress'].includes(message.toolStatus))) {
|
|
366
|
-
|
|
414
|
+
const toolDurationMs = Number(item.durationMs || 0)
|
|
415
|
+
|| (item.startedAtMs || item.createdAt ? Math.max(1, observedCompletedAtMs - Number(item.startedAtMs || item.createdAt)) : null);
|
|
416
|
+
const toolStatus = turnStatus === 'failed' ? 'failed' : turnStatus === 'cancelled' ? 'cancelled' : 'completed';
|
|
417
|
+
this.patch(item.id, { toolStatus,
|
|
418
|
+
completedAtMs: observedCompletedAtMs, ...(toolDurationMs != null ? { durationMs: toolDurationMs } : {}) });
|
|
367
419
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
this.
|
|
420
|
+
if (threadId) this.threadTurns.delete(threadId);
|
|
421
|
+
if (!threadId || threadId === this.threadId) {
|
|
422
|
+
for (const pending of this.pendingPermissions.values()) {
|
|
423
|
+
this.recordPermission(pending.public, 'denied', 'abort');
|
|
424
|
+
}
|
|
425
|
+
this.currentTurnId = null;
|
|
426
|
+
this.currentTurnStartedAt = null;
|
|
427
|
+
this.pendingPermissions.clear();
|
|
428
|
+
if (params.turn?.status === 'failed' || params.turn?.error) {
|
|
429
|
+
this.append({ kind: 'event', level: 'error', text: params.turn?.error?.message || 'Codex turn failed.' });
|
|
430
|
+
}
|
|
431
|
+
this.setStatus('idle');
|
|
432
|
+
this.hasUnreadCompletion = true;
|
|
433
|
+
} else {
|
|
434
|
+
this.emitControlState();
|
|
376
435
|
}
|
|
377
|
-
this.setStatus('idle');
|
|
378
|
-
this.hasUnreadCompletion = true;
|
|
379
436
|
return;
|
|
380
437
|
}
|
|
381
438
|
if (method === 'thread/started' || method === 'thread/resumed') {
|
|
382
439
|
const threadId = params.thread?.id || params.threadId;
|
|
383
|
-
if (threadId) { this.threadId = threadId; this.
|
|
440
|
+
if (threadId && !this.threadId) { this.threadId = threadId; this.emitControlState(); }
|
|
384
441
|
return;
|
|
385
442
|
}
|
|
386
443
|
if (method === 'thread/status/changed') {
|
|
444
|
+
const threadId = params.threadId || this.threadId;
|
|
387
445
|
const status = params.status?.type || params.status;
|
|
388
|
-
if (
|
|
389
|
-
|
|
446
|
+
if (!threadId || threadId === this.threadId) {
|
|
447
|
+
if (status === 'idle' && !this.currentTurnId) this.setStatus('idle');
|
|
448
|
+
if (status === 'active') this.setStatus('running');
|
|
449
|
+
}
|
|
390
450
|
return;
|
|
391
451
|
}
|
|
392
452
|
if (method === 'thread/settings/updated') {
|
|
@@ -430,11 +490,16 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
430
490
|
}
|
|
431
491
|
if (method.startsWith('item/')) {
|
|
432
492
|
const inferredStatus = method === 'item/completed' ? 'completed' : method === 'item/started' ? 'running' : null;
|
|
433
|
-
this.applyProviderItem(params.item || params, inferredStatus
|
|
493
|
+
this.applyProviderItem(params.item || params, inferredStatus, {
|
|
494
|
+
threadId: params.threadId || null,
|
|
495
|
+
turnId: params.turnId || null,
|
|
496
|
+
startedAtMs: Number(params.startedAtMs || 0) || null,
|
|
497
|
+
completedAtMs: Number(params.completedAtMs || 0) || null
|
|
498
|
+
});
|
|
434
499
|
}
|
|
435
500
|
}
|
|
436
501
|
|
|
437
|
-
applyProviderItem(raw, inferredStatus = null) {
|
|
502
|
+
applyProviderItem(raw, inferredStatus = null, context = {}) {
|
|
438
503
|
if (!raw || typeof raw !== 'object') return;
|
|
439
504
|
const providerId = String(raw.id || '');
|
|
440
505
|
const existing = providerId && this.messages.find(item => item.providerId === providerId);
|
|
@@ -443,10 +508,25 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
443
508
|
if (!kind) return;
|
|
444
509
|
const text = kind === 'user' ? textFromInputItems(raw.content) : kind === 'assistant' ? String(raw.text || '')
|
|
445
510
|
: kind === 'reasoning' ? (raw.text || (Array.isArray(raw.summary) ? raw.summary.join('\n') : Array.isArray(raw.content) ? raw.content.join('\n') : '')) : '';
|
|
446
|
-
const inferredToolStatus =
|
|
447
|
-
? raw.status : inferredStatus;
|
|
448
|
-
const
|
|
449
|
-
|
|
511
|
+
const inferredToolStatus = existing?.toolStatus === 'cancelled' ? 'cancelled'
|
|
512
|
+
: inferredStatus === 'completed' && ['failed', 'declined'].includes(raw.status) ? raw.status : inferredStatus;
|
|
513
|
+
const threadId = raw.threadId || context.threadId || null;
|
|
514
|
+
const trackedTurnId = threadId ? this.threadTurns.get(threadId)?.turnId : null;
|
|
515
|
+
const turnId = raw.turnId || context.turnId || trackedTurnId || this.currentTurnId;
|
|
516
|
+
const existingStartedAtMs = existing?.startedAtMs || existing?.createdAt || null;
|
|
517
|
+
const startedAtMs = context.startedAtMs || raw.startedAtMs || existingStartedAtMs;
|
|
518
|
+
const completedAtMs = context.completedAtMs || raw.completedAtMs || null;
|
|
519
|
+
const durationMs = Number(raw.durationMs || 0)
|
|
520
|
+
|| (completedAtMs && startedAtMs && completedAtMs >= startedAtMs ? completedAtMs - startedAtMs : null)
|
|
521
|
+
|| Number(existing?.durationMs || 0) || null;
|
|
522
|
+
const timing = {
|
|
523
|
+
...(startedAtMs ? { startedAtMs } : {}),
|
|
524
|
+
...(completedAtMs ? { completedAtMs } : {}),
|
|
525
|
+
...(durationMs != null ? { durationMs } : {})
|
|
526
|
+
};
|
|
527
|
+
const patch = kind === 'tool' ? { ...toolDetails(raw), threadId, turnId,
|
|
528
|
+
...timing, toolStatus: inferredToolStatus || raw.status || 'running' }
|
|
529
|
+
: { text, threadId, turnId, streaming: false };
|
|
450
530
|
if (existing) {
|
|
451
531
|
this.patch(existing.id, patch);
|
|
452
532
|
} else if (kind === 'user') {
|
|
@@ -454,7 +534,7 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
454
534
|
if (local) this.patch(local.id, { providerId, ...patch });
|
|
455
535
|
else this.append({ kind, providerId, ...patch });
|
|
456
536
|
} else {
|
|
457
|
-
this.append({ kind, providerId, ...patch });
|
|
537
|
+
this.append({ kind, providerId, ...(startedAtMs ? { createdAt: startedAtMs } : {}), ...patch });
|
|
458
538
|
}
|
|
459
539
|
}
|
|
460
540
|
|
|
@@ -501,14 +581,28 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
501
581
|
archived: false,
|
|
502
582
|
cwd: this.workingDir
|
|
503
583
|
});
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
584
|
+
const threads = (result?.data || []).filter(item => !item.parentThreadId);
|
|
585
|
+
const items = [];
|
|
586
|
+
for (const item of threads) {
|
|
587
|
+
let questions = [];
|
|
588
|
+
try {
|
|
589
|
+
const history = await this.request('thread/read', { threadId: item.id, includeTurns: true });
|
|
590
|
+
questions = recentUserQuestions(history?.thread);
|
|
591
|
+
} catch (error) {
|
|
592
|
+
this.logger.debugInfo?.(`[codex-app-server] unable to read resume preview for ${item.id}: ${error.message}`);
|
|
593
|
+
}
|
|
594
|
+
if (!questions[0]) questions[0] = item.preview || '';
|
|
595
|
+
if (questions.length < 2) questions.push('');
|
|
596
|
+
items.push({
|
|
597
|
+
id: item.id,
|
|
598
|
+
sessionId: item.sessionId || item.id,
|
|
599
|
+
questions: questions.slice(0, 2),
|
|
600
|
+
updatedAt: Number(item.updatedAt || item.createdAt || 0) * 1000,
|
|
601
|
+
cwd: item.cwd || '',
|
|
602
|
+
current: item.id === this.threadId
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
return items;
|
|
512
606
|
}
|
|
513
607
|
|
|
514
608
|
contextStatus() {
|
|
@@ -686,9 +780,16 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
686
780
|
this.recordPermission(pending.public, 'denied', 'abort');
|
|
687
781
|
}
|
|
688
782
|
this.pendingPermissions.clear();
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
783
|
+
const targets = Array.from(this.threadTurns.entries())
|
|
784
|
+
.filter(([, turn]) => turn?.turnId && turn.status === 'running')
|
|
785
|
+
.map(([threadId, turn]) => ({ threadId, turnId: turn.turnId }));
|
|
786
|
+
if (this.threadId && this.currentTurnId
|
|
787
|
+
&& !targets.some(target => target.threadId === this.threadId && target.turnId === this.currentTurnId)) {
|
|
788
|
+
targets.push({ threadId: this.threadId, turnId: this.currentTurnId });
|
|
789
|
+
}
|
|
790
|
+
for (const target of targets) {
|
|
791
|
+
this.request('turn/interrupt', target).catch(error => {
|
|
792
|
+
this.logger.debugInfo?.(`[codex-app-server] turn/interrupt failed for ${target.threadId}/${target.turnId}: ${error.message}`);
|
|
692
793
|
});
|
|
693
794
|
}
|
|
694
795
|
this.append({ kind: 'event', level: 'info', text: reason });
|
|
@@ -711,12 +812,17 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
711
812
|
this.effectivePermissionMode = result.approvalPolicy || this.effectivePermissionMode;
|
|
712
813
|
this.effectiveSandboxMode = sandboxModeFromPolicy(result.sandbox) || this.effectiveSandboxMode;
|
|
713
814
|
const history = await this.request('thread/read', { threadId: this.threadId, includeTurns: true });
|
|
714
|
-
this.
|
|
715
|
-
|
|
716
|
-
|
|
815
|
+
this.restoreThreadHistory(history?.thread, `Resumed Codex thread ${this.threadId}`);
|
|
816
|
+
return true;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
restoreThreadHistory(thread, eventText = '') {
|
|
820
|
+
this.model = thread?.model || this.model;
|
|
821
|
+
this.effort = thread?.reasoningEffort || thread?.reasoning_effort || this.effort;
|
|
822
|
+
this.tokenUsage = thread?.tokenUsage || thread?.token_usage || this.tokenUsage;
|
|
717
823
|
this.messages = [];
|
|
718
824
|
this.completedPermissions = [];
|
|
719
|
-
for (const turn of
|
|
825
|
+
for (const turn of thread?.turns || []) {
|
|
720
826
|
const status = turn.status === 'failed' ? 'failed' : turn.status === 'interrupted' ? 'cancelled' : 'completed';
|
|
721
827
|
const startedAt = Number(turn.startedAt || turn.createdAt || 0);
|
|
722
828
|
const completedAt = Number(turn.completedAt || turn.updatedAt || 0);
|
|
@@ -730,10 +836,29 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
730
836
|
this.append({ kind: 'turn-end', turnId: turn.id, status, durationMs,
|
|
731
837
|
...(completedAtMs ? { createdAt: completedAtMs } : {}) });
|
|
732
838
|
}
|
|
733
|
-
this.append({ kind: 'event', level: 'info', text:
|
|
839
|
+
if (eventText) this.append({ kind: 'event', level: 'info', text: eventText });
|
|
734
840
|
this.emitEvent({ type: 'history-reset', messages: this.messages });
|
|
735
841
|
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
736
|
-
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async forkFrom(threadId) {
|
|
845
|
+
const sourceThreadId = String(threadId || '').trim();
|
|
846
|
+
if (!sourceThreadId || this.presentation !== 'structured' || this.status !== 'idle') return false;
|
|
847
|
+
await this.ensureProcess();
|
|
848
|
+
const params = { threadId: sourceThreadId, cwd: this.workingDir, ephemeral: false, threadSource: null };
|
|
849
|
+
if (this.hasModelOverride) params.model = this.model;
|
|
850
|
+
if (this.permissionMode) params.approvalPolicy = this.permissionMode;
|
|
851
|
+
if (this.sandboxMode) params.sandbox = this.sandboxMode;
|
|
852
|
+
const result = await this.request('thread/fork', params);
|
|
853
|
+
const forkedThread = result?.thread;
|
|
854
|
+
if (!forkedThread?.id) throw new Error('Codex did not return a forked thread');
|
|
855
|
+
this.threadId = forkedThread.id;
|
|
856
|
+
this.model = result.model || forkedThread.model || this.model;
|
|
857
|
+
this.effort = result.reasoningEffort || forkedThread.reasoningEffort || forkedThread.reasoning_effort || this.effort;
|
|
858
|
+
this.effectivePermissionMode = result.approvalPolicy || this.effectivePermissionMode;
|
|
859
|
+
this.effectiveSandboxMode = sandboxModeFromPolicy(result.sandbox) || this.effectiveSandboxMode;
|
|
860
|
+
this.restoreThreadHistory(forkedThread, `Forked from Codex thread ${sourceThreadId}`);
|
|
861
|
+
return { threadId: this.threadId };
|
|
737
862
|
}
|
|
738
863
|
|
|
739
864
|
async switchToTerminal() {
|
package/lib/commands/web.js
CHANGED
|
@@ -379,6 +379,14 @@ async function webCommand(options) {
|
|
|
379
379
|
} catch (e) { res.status(400).json({ error: e.message }); }
|
|
380
380
|
});
|
|
381
381
|
|
|
382
|
+
app.post('/api/sessions/:id/codex-fork', async (req, res) => {
|
|
383
|
+
try {
|
|
384
|
+
const session = await sessionManager.forkCodex(req.params.id, req.body && req.body.threadId);
|
|
385
|
+
if (!session) return res.status(404).json({ error: 'Codex session not found' });
|
|
386
|
+
res.json({ success: true, id: session.id, name: session.name, threadId: session.threadId });
|
|
387
|
+
} catch (e) { res.status(e.statusCode || 400).json({ error: e.message }); }
|
|
388
|
+
});
|
|
389
|
+
|
|
382
390
|
app.post('/api/sessions/:id/codex-presentation', async (req, res) => {
|
|
383
391
|
const presentation = req.body && req.body.presentation;
|
|
384
392
|
if (!['terminal', 'structured'].includes(presentation)) return res.status(400).json({ error: 'Invalid presentation' });
|
|
@@ -438,6 +438,45 @@ class SessionManager extends EventEmitter {
|
|
|
438
438
|
return session && session.kind === 'codex-structured' ? session.resume(threadId) : false;
|
|
439
439
|
}
|
|
440
440
|
|
|
441
|
+
async forkCodex(id, threadId) {
|
|
442
|
+
const source = this.get(id);
|
|
443
|
+
if (!source || source.kind !== 'codex-structured') return null;
|
|
444
|
+
if (source.presentation !== 'structured' || source.status !== 'idle') {
|
|
445
|
+
const error = new Error('Codex must be idle in chat mode before forking');
|
|
446
|
+
error.statusCode = 409;
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
const sourceThreadId = String(threadId || source.threadId || '').trim();
|
|
450
|
+
if (!sourceThreadId) {
|
|
451
|
+
const error = new Error('Choose a Codex thread to fork');
|
|
452
|
+
error.statusCode = 400;
|
|
453
|
+
throw error;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const target = this.createCodexStructuredSession({
|
|
457
|
+
tool: source.tool,
|
|
458
|
+
workingDirectory: this.getSessionWorkingDirectory(source),
|
|
459
|
+
name: `${source.name} Fork`,
|
|
460
|
+
codexOptions: {
|
|
461
|
+
...(source.hasModelOverride && source.model ? { model: source.model } : {}),
|
|
462
|
+
...(source.hasEffortOverride && source.effort ? { effort: source.effort } : {}),
|
|
463
|
+
...(source.permissionMode ? { permissionMode: source.permissionMode } : {}),
|
|
464
|
+
...(source.sandboxMode ? { sandboxMode: source.sandboxMode } : {})
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
target.parentSessionId = source.id;
|
|
468
|
+
target.forkedFromThreadId = sourceThreadId;
|
|
469
|
+
try {
|
|
470
|
+
const result = await target.forkFrom(sourceThreadId);
|
|
471
|
+
if (!result) throw new Error('Unable to fork the selected Codex thread');
|
|
472
|
+
source.append({ kind: 'event', level: 'info', text: `Forked a new session: ${target.name}` });
|
|
473
|
+
return target;
|
|
474
|
+
} catch (error) {
|
|
475
|
+
this.kill(target.id);
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
441
480
|
listCodexResumeThreads(id) {
|
|
442
481
|
const session = this.get(id);
|
|
443
482
|
if (!session || session.kind !== 'codex-structured') return null;
|
package/lib/web/index.html
CHANGED
|
@@ -116,6 +116,7 @@
|
|
|
116
116
|
.codex-tool-title { flex: 0 1 auto; min-width: 0; color: #f5f5f7; font-size: 13px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
117
117
|
.codex-tool-command { flex: 1; min-width: 0; color: #a9a9b0; font: 12px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
118
118
|
.codex-tool-state { flex: 0 0 auto; color: #8e8e93; font-size: 11px; }
|
|
119
|
+
.codex-tool-duration { flex: 0 0 auto; color: #6f6f76; font-size: 10px; font-variant-numeric: tabular-nums; }
|
|
119
120
|
.codex-tool-state.running::before { content: ''; display: inline-block; width: 9px; height: 9px; margin-right: 5px; border: 1.5px solid #8e8e93; border-top-color: transparent; border-radius: 50%; animation: codex-spin .8s linear infinite; vertical-align: -1px; }
|
|
120
121
|
.codex-tool.error { border-color: rgba(255,59,48,.38); background: rgba(255,59,48,.07); }
|
|
121
122
|
.codex-tool-body { border-top: 1px solid rgba(255,255,255,.08); padding: 9px 10px; }
|
|
@@ -142,16 +143,18 @@
|
|
|
142
143
|
.codex-inline-permission .claude-permission-actions { margin-top: 8px; }
|
|
143
144
|
@keyframes codex-spin { to { transform: rotate(360deg); } }
|
|
144
145
|
#codex-control-panel { display: none; width: min(100%, var(--control-content-max)); margin: 0 auto; padding: 8px 14px 10px; border-bottom: 1px solid #222; background: #121212; box-sizing: border-box; }
|
|
145
|
-
.codex-control-
|
|
146
|
-
.codex-control-
|
|
147
|
-
.codex-control-
|
|
146
|
+
.codex-control-rail { display: flex; gap: 14px; overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
|
|
147
|
+
.codex-control-rail::-webkit-scrollbar { display: none; }
|
|
148
|
+
.codex-control-page { flex: 0 0 100%; display: grid; grid-template-columns: repeat(5, minmax(0, 116px)); grid-template-rows: auto; justify-content: center; gap: clamp(2px, .8vw, 7px); align-items: center; white-space: nowrap; scroll-snap-align: start; box-sizing: border-box; }
|
|
149
|
+
.codex-control-page > * { width: 100%; min-width: 0; overflow: hidden; }
|
|
150
|
+
.codex-control-page .claude-ctrl-btn, .codex-control-page .codex-select-label { padding-left: clamp(2px, 1vw, 10px); padding-right: clamp(2px, 1vw, 10px); font-size: clamp(8px, 2.2vw, 11px); text-overflow: ellipsis; overflow: hidden; }
|
|
148
151
|
.codex-select-control { position: relative; display: block; min-width: 0; height: 32px; }
|
|
149
152
|
.codex-select-label { display: flex; width: 100%; height: 100%; align-items: center; justify-content: center; box-sizing: border-box; border: 1px solid rgba(255,255,255,.1); border-radius: 16px; background: rgba(255,255,255,.08); color: #f5f5f7; font-size: 11px; font-weight: 800; white-space: nowrap; }
|
|
150
153
|
.codex-select-control:focus-within .codex-select-label { background: rgba(255,255,255,.14); border-color: rgba(0,122,255,.45); }
|
|
151
154
|
.codex-select { appearance: none; position: absolute; inset: 0; width: 100%; min-width: 0; height: 100%; opacity: 0; cursor: pointer; }
|
|
152
155
|
.codex-select option { background: #1c1c1e; color: #fff; }
|
|
153
|
-
#codex-model-panel, #codex-resume-panel { display: none; margin-top: 8px; border: 1px solid rgba(255,255,255,.1); background: rgba(28,28,30,.99); border-radius: 8px; box-shadow: 0 16px 36px rgba(0,0,0,.34); overflow: hidden; }
|
|
154
|
-
#codex-model-panel.active, #codex-resume-panel.active { display: block; }
|
|
156
|
+
#codex-model-panel, #codex-resume-panel, #codex-fork-panel { display: none; margin-top: 8px; border: 1px solid rgba(255,255,255,.1); background: rgba(28,28,30,.99); border-radius: 8px; box-shadow: 0 16px 36px rgba(0,0,0,.34); overflow: hidden; }
|
|
157
|
+
#codex-model-panel.active, #codex-resume-panel.active, #codex-fork-panel.active { display: block; }
|
|
155
158
|
#codex-model-panel { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); max-height: min(300px, 42dvh); }
|
|
156
159
|
#codex-model-panel.active { display: grid; }
|
|
157
160
|
.codex-picker-column { min-width: 0; overflow-y: auto; }
|
|
@@ -160,7 +163,7 @@
|
|
|
160
163
|
.codex-picker-option.selected { background: rgba(0,122,255,.18); color: #fff; }
|
|
161
164
|
#codex-state-bar { display: flex; align-items: center; gap: 7px; min-height: 24px; margin-top: 8px; color: var(--text-dim); font-size: 11px; overflow-x: auto; scrollbar-width: none; white-space: nowrap; }
|
|
162
165
|
#codex-state-bar::-webkit-scrollbar { display: none; }
|
|
163
|
-
#codex-resume-panel { max-height: min(280px, 38dvh); overflow-y: auto; }
|
|
166
|
+
#codex-resume-panel, #codex-fork-panel { max-height: min(280px, 38dvh); overflow-y: auto; }
|
|
164
167
|
.claude-context-size-badge { position: sticky; top: 0; z-index: 2; width: max-content; max-width: 100%; margin: 0 0 8px auto; border: 1px solid rgba(255,255,255,0.1); background: rgba(28,28,30,0.94); border-radius: 999px; padding: 4px 9px; color: #d1d5db; font-size: 11px; font-weight: 800; box-shadow: 0 8px 18px rgba(0,0,0,0.24); }
|
|
165
168
|
.claude-message { max-width: 92%; margin: 0 0 10px 0; padding: 10px 12px; border-radius: 8px; overflow-wrap: anywhere; line-height: 1.45; font-size: 14px; }
|
|
166
169
|
.claude-message.user { margin-left: auto; background: rgba(0,122,255,0.24); border: 1px solid rgba(0,122,255,0.32); color: #fff; border-radius: 12px; padding-top: 7px; padding-bottom: 7px; }
|
|
@@ -257,6 +260,7 @@
|
|
|
257
260
|
.claude-resume-item:last-child { border-bottom: 0; }
|
|
258
261
|
.claude-resume-item:active { background: rgba(255,255,255,0.08); }
|
|
259
262
|
.claude-resume-title { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; font-weight: 800; }
|
|
263
|
+
.codex-resume-question-secondary { min-height: 1.35em; color: var(--text-dim); font-size: 11px; margin-top: 3px; overflow-wrap: anywhere; }
|
|
260
264
|
.claude-resume-meta { color: var(--text-dim); font-size: 11px; margin-top: 3px; overflow-wrap: anywhere; }
|
|
261
265
|
#terminal .xterm, #terminal .xterm-viewport, #terminal .xterm-screen, #terminal .xterm-rows, #terminal .xterm-rows span { user-select: text !important; -webkit-user-select: text !important; -webkit-touch-callout: default; touch-action: pan-y; }
|
|
262
266
|
#terminal .xterm-viewport { overflow-anchor: none; }
|
|
@@ -319,8 +323,8 @@
|
|
|
319
323
|
#nav-bar > div:last-child .icon-btn { min-width: 34px; min-height: 30px; padding: 5px 7px !important; }
|
|
320
324
|
#input-row { padding-left: 10px; padding-right: 10px; }
|
|
321
325
|
#codex-control-panel { padding-left: 8px; padding-right: 8px; }
|
|
322
|
-
.codex-control-
|
|
323
|
-
.codex-control-
|
|
326
|
+
.codex-control-page { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
|
327
|
+
.codex-control-page .claude-ctrl-btn { height: 36px; padding-left: 4px; padding-right: 4px; }
|
|
324
328
|
.codex-select-control { height: 36px; }
|
|
325
329
|
#codex-chat-container { padding: 10px 10px calc(22px + env(safe-area-inset-bottom)); }
|
|
326
330
|
.codex-message.user { max-width: 94%; }
|
|
@@ -441,27 +445,33 @@
|
|
|
441
445
|
<div id="claude-resume-panel"></div>
|
|
442
446
|
</div>
|
|
443
447
|
<div id="codex-control-panel">
|
|
444
|
-
<div class="codex-control-
|
|
445
|
-
<
|
|
446
|
-
<
|
|
447
|
-
<
|
|
448
|
-
|
|
449
|
-
</
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
<
|
|
454
|
-
<
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
448
|
+
<div id="codex-control-rail" class="codex-control-rail">
|
|
449
|
+
<div class="codex-control-page">
|
|
450
|
+
<button id="codex-model-btn" class="claude-ctrl-btn" onclick="toggleCodexModelPanel()" title="Model and effort">Model</button>
|
|
451
|
+
<button id="codex-status-btn" class="claude-ctrl-btn" onclick="requestCodexStatus()" title="Show Codex account, limits, and context status">Status</button>
|
|
452
|
+
<button id="codex-abort-btn" class="claude-ctrl-btn danger" onclick="abortCodexSession()" title="Abort current Codex turn">Abort</button>
|
|
453
|
+
<button id="codex-resume-btn" class="claude-ctrl-btn primary" onclick="toggleCodexResumePanel()" title="Resume a Codex thread">Resume</button>
|
|
454
|
+
<button id="codex-fork-btn" class="claude-ctrl-btn primary" onclick="toggleCodexForkPanel()" title="Fork a Codex thread into a new Glad session">Fork</button>
|
|
455
|
+
</div>
|
|
456
|
+
<div class="codex-control-page">
|
|
457
|
+
<label class="codex-select-control" title="Sandbox mode">
|
|
458
|
+
<span class="codex-select-label" aria-hidden="true">Sandbox</span>
|
|
459
|
+
<select id="codex-sandbox-select" class="codex-select" aria-label="Sandbox mode" onchange="updateCodexSettingsFromControls()">
|
|
460
|
+
<option value="default">Default</option><option value="read-only">Read only</option><option value="workspace-write">Workspace write</option><option value="danger-full-access">Full access</option>
|
|
461
|
+
</select>
|
|
462
|
+
</label>
|
|
463
|
+
<label class="codex-select-control" title="Approval policy">
|
|
464
|
+
<span class="codex-select-label" aria-hidden="true">Ask</span>
|
|
465
|
+
<select id="codex-permission-select" class="codex-select" aria-label="Approval policy" onchange="updateCodexSettingsFromControls()">
|
|
466
|
+
<option value="default">Default</option><option value="untrusted">Untrusted</option><option value="on-request">On request</option><option value="never">Never ask</option>
|
|
467
|
+
</select>
|
|
468
|
+
</label>
|
|
469
|
+
</div>
|
|
461
470
|
</div>
|
|
462
471
|
<div id="codex-state-bar"></div>
|
|
463
472
|
<div id="codex-model-panel"></div>
|
|
464
473
|
<div id="codex-resume-panel"></div>
|
|
474
|
+
<div id="codex-fork-panel"></div>
|
|
465
475
|
</div>
|
|
466
476
|
<div id="timed-send-panel">
|
|
467
477
|
<div class="timed-row">
|
|
@@ -658,6 +668,8 @@
|
|
|
658
668
|
let codexModelPanelOpen = false;
|
|
659
669
|
let codexModelCandidate = null;
|
|
660
670
|
let codexResumePanelOpen = false;
|
|
671
|
+
let codexForkPanelOpen = false;
|
|
672
|
+
let codexRenderFrame = null;
|
|
661
673
|
const modifiers = { ctrl: false };
|
|
662
674
|
|
|
663
675
|
function log(msg) {
|
|
@@ -2070,6 +2082,16 @@
|
|
|
2070
2082
|
if (status === 'completed') return item.exitCode && item.exitCode !== 0 ? 'failed' : 'completed';
|
|
2071
2083
|
return status;
|
|
2072
2084
|
}
|
|
2085
|
+
function formatCodexDuration(durationMs) {
|
|
2086
|
+
const value = Number(durationMs || 0);
|
|
2087
|
+
if (!(value > 0)) return '';
|
|
2088
|
+
if (value < 1000) return `${Math.round(value)}ms`;
|
|
2089
|
+
const seconds = value / 1000;
|
|
2090
|
+
if (seconds < 10) return `${seconds.toFixed(1).replace(/\.0$/, '')}s`;
|
|
2091
|
+
if (seconds < 60) return `${Math.round(seconds)}s`;
|
|
2092
|
+
const minutes = Math.floor(seconds / 60);
|
|
2093
|
+
return `${minutes}m ${Math.round(seconds % 60)}s`;
|
|
2094
|
+
}
|
|
2073
2095
|
function renderCodexDiff(diff) {
|
|
2074
2096
|
return `<div class="codex-diff">${String(diff || '').split('\n').map(line => {
|
|
2075
2097
|
const type = line.startsWith('+++') || line.startsWith('---') ? 'hunk' : line.startsWith('+') ? 'add' : line.startsWith('-') ? 'del' : line.startsWith('@@') ? 'hunk' : '';
|
|
@@ -2153,27 +2175,72 @@
|
|
|
2153
2175
|
const command = item.name === 'CodexBash' ? item.command : item.title || item.tool || '';
|
|
2154
2176
|
const icon = item.name === 'CodexBash' ? '>_' : item.name === 'McpTool' ? 'MCP' : item.name === 'Agent' ? 'A' : '•';
|
|
2155
2177
|
const title = item.name === 'CodexBash' ? 'Command' : item.title || item.name || 'Tool';
|
|
2156
|
-
const
|
|
2157
|
-
|
|
2178
|
+
const hasInput = item.input && typeof item.input === 'object' && Object.keys(item.input).length > 0;
|
|
2179
|
+
const result = item.result || ((item.name === 'McpTool' || item.name === 'Agent') && hasInput ? codexJson(item.input) : '');
|
|
2180
|
+
const duration = status === 'running' ? '' : formatCodexDuration(item.durationMs);
|
|
2181
|
+
return `<details class="codex-tool${isError ? ' error' : ''}" data-codex-key="tool-${escapeHtml(item.id || item.providerId || '')}"><summary class="codex-tool-header"><span class="codex-tool-icon">${escapeHtml(icon)}</span><span class="codex-tool-title">${escapeHtml(title)}</span>${command && command !== title ? `<span class="codex-tool-command">${escapeHtml(command)}</span>` : '<span class="codex-tool-command"></span>'}${duration ? `<span class="codex-tool-duration">${escapeHtml(duration)}</span>` : ''}<span class="codex-tool-state${runningClass}">${escapeHtml(status === 'completed' ? '' : status)}</span></summary>${result ? `<div class="codex-tool-body"><pre class="codex-tool-code">${escapeHtml(result)}</pre></div>` : ''}${permission ? renderCodexPermission(permission, true) : ''}</details>`;
|
|
2158
2182
|
}
|
|
2159
2183
|
function renderCodexToolGroup(items, permissionById, usedPermissions, turnEnd = null) {
|
|
2160
2184
|
const running = items.some(item => codexToolStatus(item) === 'running');
|
|
2161
2185
|
const failed = items.some(item => codexToolStatus(item) === 'failed' || item.error);
|
|
2162
|
-
const startedAt = Math.min(...items.map(item => Number(item.createdAt || Date.now())));
|
|
2186
|
+
const startedAt = Math.min(...items.map(item => Number(item.startedAtMs || item.createdAt || Date.now())));
|
|
2187
|
+
const itemCompletedAt = Math.max(...items.map(item => Number(item.completedAtMs || 0)));
|
|
2163
2188
|
const storedDuration = Number(turnEnd?.durationMs || 0);
|
|
2164
2189
|
const completedAt = Number(turnEnd?.createdAt || 0);
|
|
2165
|
-
const durationMs = storedDuration > 0 ? storedDuration
|
|
2190
|
+
const durationMs = itemCompletedAt >= startedAt ? itemCompletedAt - startedAt : storedDuration > 0 ? storedDuration
|
|
2166
2191
|
: (!running && completedAt >= startedAt ? completedAt - startedAt : 0);
|
|
2167
|
-
const
|
|
2192
|
+
const duration = formatCodexDuration(durationMs);
|
|
2168
2193
|
const tools = items.map(item => {
|
|
2169
2194
|
const permission = permissionById.get(String(item.providerId || ''));
|
|
2170
2195
|
if (permission) usedPermissions.add(permission.id);
|
|
2171
2196
|
return renderCodexTool(item, permission);
|
|
2172
2197
|
}).join('');
|
|
2173
|
-
const label = running ? 'Working' : failed ? 'Work finished with errors' :
|
|
2174
|
-
|
|
2198
|
+
const label = running ? 'Working' : failed ? 'Work finished with errors' : duration ? `Worked for ${duration}` : 'Worked';
|
|
2199
|
+
const key = items.map(item => item.id || item.providerId || '').join('-');
|
|
2200
|
+
return `<details class="codex-work-group" data-codex-key="group-${escapeHtml(key)}"${running ? ' open' : ''}><summary>${label} · ${items.length} tools</summary><div class="codex-work-group-body">${tools}</div></details>`;
|
|
2201
|
+
}
|
|
2202
|
+
function syncCodexDom(current, next) {
|
|
2203
|
+
if (!current || !next) return;
|
|
2204
|
+
if (current.nodeType !== next.nodeType || current.nodeName !== next.nodeName) {
|
|
2205
|
+
current.replaceWith(next.cloneNode(true));
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
if (current.nodeType === Node.TEXT_NODE) {
|
|
2209
|
+
if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue;
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
const currentKey = current.getAttribute?.('data-codex-key');
|
|
2213
|
+
const nextKey = next.getAttribute?.('data-codex-key');
|
|
2214
|
+
if (currentKey && nextKey && currentKey !== nextKey) {
|
|
2215
|
+
current.replaceWith(next.cloneNode(true));
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
2218
|
+
const preserveOpen = current.tagName === 'DETAILS' && currentKey === nextKey;
|
|
2219
|
+
const wasOpen = preserveOpen ? current.open : false;
|
|
2220
|
+
for (const attribute of Array.from(current.attributes || [])) {
|
|
2221
|
+
if (!next.hasAttribute(attribute.name) && !(preserveOpen && attribute.name === 'open')) current.removeAttribute(attribute.name);
|
|
2222
|
+
}
|
|
2223
|
+
for (const attribute of Array.from(next.attributes || [])) {
|
|
2224
|
+
if (!(preserveOpen && attribute.name === 'open') && current.getAttribute(attribute.name) !== attribute.value) {
|
|
2225
|
+
current.setAttribute(attribute.name, attribute.value);
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
if (preserveOpen) current.open = wasOpen;
|
|
2229
|
+
const currentChildren = Array.from(current.childNodes);
|
|
2230
|
+
const nextChildren = Array.from(next.childNodes);
|
|
2231
|
+
const shared = Math.min(currentChildren.length, nextChildren.length);
|
|
2232
|
+
for (let i = 0; i < shared; i++) syncCodexDom(currentChildren[i], nextChildren[i]);
|
|
2233
|
+
for (let i = current.childNodes.length - 1; i >= nextChildren.length; i--) current.childNodes[i].remove();
|
|
2234
|
+
for (let i = shared; i < nextChildren.length; i++) current.appendChild(nextChildren[i].cloneNode(true));
|
|
2175
2235
|
}
|
|
2176
2236
|
function renderCodexChat() {
|
|
2237
|
+
if (codexRenderFrame != null) return;
|
|
2238
|
+
codexRenderFrame = requestAnimationFrame(() => {
|
|
2239
|
+
codexRenderFrame = null;
|
|
2240
|
+
commitCodexChatRender();
|
|
2241
|
+
});
|
|
2242
|
+
}
|
|
2243
|
+
function commitCodexChatRender() {
|
|
2177
2244
|
const container = document.getElementById('codex-chat-container');
|
|
2178
2245
|
if (!container) return;
|
|
2179
2246
|
const parts = [];
|
|
@@ -2197,16 +2264,20 @@
|
|
|
2197
2264
|
}
|
|
2198
2265
|
continue;
|
|
2199
2266
|
}
|
|
2200
|
-
if (item.kind === 'assistant') parts.push(`<div class="codex-message assistant claude-md">${renderMarkdown(item.text || '')}</div>`);
|
|
2201
|
-
else if (item.kind === 'user') parts.push(`<div class="codex-message user claude-md">${renderMarkdown(item.text || '')}</div>`);
|
|
2267
|
+
if (item.kind === 'assistant') parts.push(`<div class="codex-message assistant claude-md" data-codex-key="message-${escapeHtml(item.id || '')}">${renderMarkdown(item.text || '')}</div>`);
|
|
2268
|
+
else if (item.kind === 'user') parts.push(`<div class="codex-message user claude-md" data-codex-key="message-${escapeHtml(item.id || '')}">${renderMarkdown(item.text || '')}</div>`);
|
|
2202
2269
|
else if (item.kind === 'status') parts.push(renderCodexStatus(item));
|
|
2203
2270
|
else parts.push(`<div class="codex-message ${escapeHtml(item.level === 'error' ? 'event error' : item.kind || 'event')}">${codexText(item.text)}</div>`);
|
|
2204
2271
|
i += 1;
|
|
2205
2272
|
}
|
|
2206
2273
|
for (const request of codexPendingPermissions) if (!usedPermissions.has(request.id)) parts.push(renderCodexPermission(request));
|
|
2207
|
-
const working = codexState.status === 'running'
|
|
2208
|
-
|
|
2209
|
-
|
|
2274
|
+
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>`;
|
|
2275
|
+
const template = document.createElement('template');
|
|
2276
|
+
template.innerHTML = `<div class="codex-conversation">${working}${parts.join('') || '<div class="codex-message event">Send a message to start Codex.</div>'}</div>`;
|
|
2277
|
+
const next = template.content.firstElementChild;
|
|
2278
|
+
const current = container.firstElementChild;
|
|
2279
|
+
if (!current) container.appendChild(next);
|
|
2280
|
+
else syncCodexDom(current, next);
|
|
2210
2281
|
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
|
|
2211
2282
|
}
|
|
2212
2283
|
|
|
@@ -2235,6 +2306,8 @@
|
|
|
2235
2306
|
if (modelButton) modelButton.textContent = 'Model';
|
|
2236
2307
|
const abort = document.getElementById('codex-abort-btn');
|
|
2237
2308
|
if (abort) abort.disabled = !codexState.canAbort;
|
|
2309
|
+
const fork = document.getElementById('codex-fork-btn');
|
|
2310
|
+
if (fork) fork.disabled = !(codexState.presentation === 'structured' && codexState.status === 'idle' && codexState.threadId);
|
|
2238
2311
|
const terminal = document.getElementById('codex-terminal-switch');
|
|
2239
2312
|
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'; }
|
|
2240
2313
|
renderCodexStateBar();
|
|
@@ -2247,8 +2320,10 @@
|
|
|
2247
2320
|
const el = document.getElementById('codex-state-bar');
|
|
2248
2321
|
if (!el) return;
|
|
2249
2322
|
const pending = Number(codexState.pendingPermissionCount || codexPendingPermissions.filter(item => item.status === 'pending').length) || 0;
|
|
2323
|
+
const subagents = Number(codexState.activeSubagentCount || 0) || 0;
|
|
2250
2324
|
const parts = [];
|
|
2251
2325
|
if (pending || codexState.status === 'waiting_approval') parts.push(`<span class="claude-state-pill warn">${pending || 1} approval${pending === 1 ? '' : 's'}</span>`);
|
|
2326
|
+
if (subagents) parts.push(`<span class="claude-state-pill">${subagents} subagent${subagents === 1 ? '' : 's'} running</span>`);
|
|
2252
2327
|
el.innerHTML = parts.join('');
|
|
2253
2328
|
el.style.display = parts.length ? 'flex' : 'none';
|
|
2254
2329
|
}
|
|
@@ -2256,8 +2331,10 @@
|
|
|
2256
2331
|
function toggleCodexModelPanel() {
|
|
2257
2332
|
codexModelPanelOpen = !codexModelPanelOpen;
|
|
2258
2333
|
codexResumePanelOpen = false;
|
|
2334
|
+
codexForkPanelOpen = false;
|
|
2259
2335
|
codexModelCandidate = codexState.model || codexState.models?.[0]?.id || null;
|
|
2260
2336
|
document.getElementById('codex-resume-panel').classList.remove('active');
|
|
2337
|
+
document.getElementById('codex-fork-panel').classList.remove('active');
|
|
2261
2338
|
renderCodexModelPanel();
|
|
2262
2339
|
updateTerminalControlsHeight();
|
|
2263
2340
|
}
|
|
@@ -2306,19 +2383,42 @@
|
|
|
2306
2383
|
async function toggleCodexResumePanel() {
|
|
2307
2384
|
codexResumePanelOpen = !codexResumePanelOpen;
|
|
2308
2385
|
codexModelPanelOpen = false;
|
|
2386
|
+
codexForkPanelOpen = false;
|
|
2309
2387
|
document.getElementById('codex-model-panel').classList.remove('active');
|
|
2388
|
+
document.getElementById('codex-fork-panel').classList.remove('active');
|
|
2310
2389
|
const panel = document.getElementById('codex-resume-panel');
|
|
2311
2390
|
panel.classList.toggle('active', codexResumePanelOpen);
|
|
2312
2391
|
updateTerminalControlsHeight();
|
|
2313
2392
|
if (!codexResumePanelOpen) return;
|
|
2393
|
+
await loadCodexThreadPanel(panel, 'resume');
|
|
2394
|
+
}
|
|
2395
|
+
async function toggleCodexForkPanel() {
|
|
2396
|
+
if (!(codexState.presentation === 'structured' && codexState.status === 'idle' && codexState.threadId)) return;
|
|
2397
|
+
codexForkPanelOpen = !codexForkPanelOpen;
|
|
2398
|
+
codexModelPanelOpen = false;
|
|
2399
|
+
codexResumePanelOpen = false;
|
|
2400
|
+
document.getElementById('codex-model-panel').classList.remove('active');
|
|
2401
|
+
document.getElementById('codex-resume-panel').classList.remove('active');
|
|
2402
|
+
const panel = document.getElementById('codex-fork-panel');
|
|
2403
|
+
panel.classList.toggle('active', codexForkPanelOpen);
|
|
2404
|
+
updateTerminalControlsHeight();
|
|
2405
|
+
if (!codexForkPanelOpen) return;
|
|
2406
|
+
await loadCodexThreadPanel(panel, 'fork');
|
|
2407
|
+
}
|
|
2408
|
+
async function loadCodexThreadPanel(panel, action) {
|
|
2314
2409
|
panel.innerHTML = '<div class="claude-resume-meta" style="padding:12px;">Loading sessions...</div>';
|
|
2315
2410
|
try {
|
|
2316
2411
|
const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-resume-threads`, {}, 30000);
|
|
2317
2412
|
const data = await res.json();
|
|
2318
2413
|
if (!res.ok || !data.success) throw new Error(data.error || 'Failed to load Codex sessions');
|
|
2319
2414
|
const items = data.items || [];
|
|
2320
|
-
panel.innerHTML = items.length ? items.map(item =>
|
|
2415
|
+
panel.innerHTML = items.length ? items.map(item => {
|
|
2416
|
+
const questions = Array.isArray(item.questions) ? item.questions : [];
|
|
2417
|
+
const handler = action === 'fork' ? 'selectCodexForkThread' : 'selectCodexResumeThread';
|
|
2418
|
+
return `<button class="claude-resume-item" onclick="${handler}(decodePathValue('${encodePathValue(item.id)}'))"><div class="claude-resume-title"><span>${escapeHtml((questions[0] || 'Codex session').slice(0, 120))}${item.current ? ' · current' : ''}</span><span>${escapeHtml(item.updatedAt ? new Date(item.updatedAt).toLocaleString() : '')}</span></div><div class="codex-resume-question-secondary">${escapeHtml((questions[1] || '').slice(0, 120))}</div></button>`;
|
|
2419
|
+
}).join('') : '<div class="claude-resume-meta" style="padding:12px;">No Codex sessions found for this folder.</div>';
|
|
2321
2420
|
} catch (e) { panel.innerHTML = `<div class="claude-resume-meta" style="padding:12px;color:#ff6b61;">${escapeHtml(e.message)}</div>`; }
|
|
2421
|
+
updateTerminalControlsHeight();
|
|
2322
2422
|
}
|
|
2323
2423
|
async function selectCodexResumeThread(threadId) {
|
|
2324
2424
|
const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-resume`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ threadId }) }, 30000);
|
|
@@ -2327,6 +2427,23 @@
|
|
|
2327
2427
|
document.getElementById('codex-resume-panel').classList.remove('active');
|
|
2328
2428
|
updateTerminalControlsHeight();
|
|
2329
2429
|
}
|
|
2430
|
+
async function selectCodexForkThread(threadId) {
|
|
2431
|
+
const panel = document.getElementById('codex-fork-panel');
|
|
2432
|
+
panel.innerHTML = '<div class="claude-resume-meta" style="padding:12px;">Forking into a new session...</div>';
|
|
2433
|
+
updateTerminalControlsHeight();
|
|
2434
|
+
const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-fork`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ threadId }) }, 60000);
|
|
2435
|
+
const data = await res.json();
|
|
2436
|
+
if (!res.ok || !data.success) {
|
|
2437
|
+
panel.innerHTML = `<div class="claude-resume-meta" style="padding:12px;color:#ff6b61;">${escapeHtml(data.error || 'Unable to fork Codex thread')}</div>`;
|
|
2438
|
+
updateTerminalControlsHeight();
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
codexForkPanelOpen = false;
|
|
2442
|
+
panel.classList.remove('active');
|
|
2443
|
+
panel.innerHTML = '';
|
|
2444
|
+
updateTerminalControlsHeight();
|
|
2445
|
+
refreshSessionsNow();
|
|
2446
|
+
}
|
|
2330
2447
|
async function toggleCodexPresentation() {
|
|
2331
2448
|
const presentation = codexState.presentation === 'terminal' ? 'structured' : 'terminal';
|
|
2332
2449
|
const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-presentation`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ presentation }) }, 30000);
|
|
@@ -2461,8 +2578,11 @@
|
|
|
2461
2578
|
codexModelPanelOpen = false;
|
|
2462
2579
|
codexModelCandidate = null;
|
|
2463
2580
|
codexResumePanelOpen = false;
|
|
2581
|
+
codexForkPanelOpen = false;
|
|
2464
2582
|
document.getElementById('codex-model-panel').classList.remove('active');
|
|
2465
2583
|
document.getElementById('codex-resume-panel').classList.remove('active');
|
|
2584
|
+
document.getElementById('codex-fork-panel').classList.remove('active');
|
|
2585
|
+
document.getElementById('codex-control-rail').scrollLeft = 0;
|
|
2466
2586
|
codexState = { permissionMode: 'default', sandboxMode: 'default', effectivePermissionMode: null, effectiveSandboxMode: null, model: null, effort: null, status: 'idle', threadId: null, presentation: 'structured', models: [], canAbort: false, canSwitchToTerminal: false, canSwitchToStructured: false };
|
|
2467
2587
|
setClaudeModeEnabled(false);
|
|
2468
2588
|
applyCodexState(codexState);
|