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.
@@ -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
- input: raw.arguments || raw.input || raw,
105
- result: raw.error != null ? String(raw.error) : safeJson(raw.result || ''),
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 || raw.agentId || raw.id || null
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
- this.currentTurnId = params.turn?.id || params.turnId || this.currentTurnId;
353
- this.currentTurnStartedAt = Date.now();
354
- this.append({ kind: 'turn-start', turnId: this.currentTurnId });
355
- this.setStatus('running');
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 completedTurnId = params.turn?.id || params.turnId || this.currentTurnId;
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
- this.append({ kind: 'turn-end', turnId: completedTurnId, status: turnStatus,
363
- durationMs: this.currentTurnStartedAt ? Date.now() - this.currentTurnStartedAt : null });
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
- this.patch(item.id, { toolStatus: turnStatus === 'failed' ? 'failed' : 'completed' });
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
- for (const pending of this.pendingPermissions.values()) {
369
- this.recordPermission(pending.public, 'denied', 'abort');
370
- }
371
- this.currentTurnId = null;
372
- this.currentTurnStartedAt = null;
373
- this.pendingPermissions.clear();
374
- if (params.turn?.status === 'failed' || params.turn?.error) {
375
- this.append({ kind: 'event', level: 'error', text: params.turn?.error?.message || 'Codex turn failed.' });
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.emitEvent({ type: 'state', state: this.getControlState() }); }
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 (status === 'idle') this.setStatus('idle');
389
- if (status === 'active') this.setStatus('running');
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 = inferredStatus === 'completed' && ['failed', 'declined'].includes(raw.status)
447
- ? raw.status : inferredStatus;
448
- const patch = kind === 'tool' ? { ...toolDetails(raw), turnId: raw.turnId || this.currentTurnId,
449
- toolStatus: inferredToolStatus || raw.status || 'running' } : { text, turnId: raw.turnId || this.currentTurnId, streaming: false };
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
- return (result?.data || []).filter(item => !item.parentThreadId).map(item => ({
505
- id: item.id,
506
- sessionId: item.sessionId || item.id,
507
- preview: item.preview || item.name || '',
508
- updatedAt: Number(item.updatedAt || item.createdAt || 0) * 1000,
509
- cwd: item.cwd || '',
510
- current: item.id === this.threadId
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
- if (this.threadId && this.currentTurnId) {
690
- this.request('turn/interrupt', { threadId: this.threadId, turnId: this.currentTurnId }).catch(error => {
691
- this.logger.debugInfo?.(`[codex-app-server] turn/interrupt failed: ${error.message}`);
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.model = history?.thread?.model || this.model;
715
- this.effort = history?.thread?.reasoningEffort || history?.thread?.reasoning_effort || this.effort;
716
- this.tokenUsage = history?.thread?.tokenUsage || history?.thread?.token_usage || this.tokenUsage;
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 history?.thread?.turns || []) {
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: `Resumed Codex thread ${this.threadId}` });
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
- return true;
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() {
@@ -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;
@@ -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-row { display: grid; grid-template-columns: repeat(6, minmax(0, 116px)); grid-template-rows: auto; justify-content: center; gap: clamp(2px, .8vw, 7px); align-items: center; white-space: nowrap; }
146
- .codex-control-row > * { width: 100%; min-width: 0; overflow: hidden; }
147
- .codex-control-row .claude-ctrl-btn, .codex-control-row .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; }
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-row { grid-template-columns: repeat(6, minmax(0, 1fr)); }
323
- .codex-control-row .claude-ctrl-btn { height: 36px; padding-left: 4px; padding-right: 4px; }
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-row">
445
- <label class="codex-select-control" title="Sandbox mode">
446
- <span class="codex-select-label" aria-hidden="true">Sandbox</span>
447
- <select id="codex-sandbox-select" class="codex-select" aria-label="Sandbox mode" onchange="updateCodexSettingsFromControls()">
448
- <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>
449
- </select>
450
- </label>
451
- <label class="codex-select-control" title="Approval policy">
452
- <span class="codex-select-label" aria-hidden="true">Ask</span>
453
- <select id="codex-permission-select" class="codex-select" aria-label="Approval policy" onchange="updateCodexSettingsFromControls()">
454
- <option value="default">Default</option><option value="untrusted">Untrusted</option><option value="on-request">On request</option><option value="never">Never ask</option>
455
- </select>
456
- </label>
457
- <button id="codex-model-btn" class="claude-ctrl-btn" onclick="toggleCodexModelPanel()" title="Model and effort">Model</button>
458
- <button id="codex-status-btn" class="claude-ctrl-btn" onclick="requestCodexStatus()" title="Show Codex account, limits, and context status">Status</button>
459
- <button id="codex-abort-btn" class="claude-ctrl-btn danger" onclick="abortCodexSession()" title="Abort current Codex turn">Abort</button>
460
- <button id="codex-resume-btn" class="claude-ctrl-btn primary" onclick="toggleCodexResumePanel()" title="Resume a Codex thread">Resume</button>
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 result = item.result || (item.name === 'McpTool' || item.name === 'Agent' ? codexJson(item.input) : '');
2157
- return `<details class="codex-tool${isError ? ' error' : ''}"><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>'}<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>`;
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 seconds = durationMs > 0 ? Math.max(1, Math.round(durationMs / 1000)) : null;
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' : seconds ? `Worked for ${seconds}s` : 'Worked';
2174
- return `<details class="codex-work-group"${running ? ' open' : ''}><summary>${label} · ${items.length} tools</summary><div class="codex-work-group-body">${tools}</div></details>`;
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
- ? '<div class="codex-working-indicator" role="status" aria-label="Codex is working" title="Codex is working"></div>' : '';
2209
- container.innerHTML = `<div class="codex-conversation">${working}${parts.join('') || '<div class="codex-message event">Send a message to start Codex.</div>'}</div>`;
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 => `<button class="claude-resume-item" onclick="selectCodexResumeThread(decodePathValue('${encodePathValue(item.id)}'))"><div class="claude-resume-title"><span>${escapeHtml((item.preview || 'Codex session').slice(0, 48))}${item.current ? ' · current' : ''}</span><span>${escapeHtml(item.updatedAt ? new Date(item.updatedAt).toLocaleString() : '')}</span></div><div class="claude-resume-meta">${escapeHtml(item.id.slice(0, 12))}</div></button>`).join('') : '<div class="claude-resume-meta" style="padding:12px;">No Codex sessions found for this folder.</div>';
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glad-web",
3
- "version": "1.0.25",
3
+ "version": "1.0.27",
4
4
  "description": "Glad transforms terminal-based AI coding tools into a polished, mobile-friendly local Web interface.",
5
5
  "main": "index.js",
6
6
  "bin": {