glad-web 1.0.23 → 1.0.25

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.
@@ -43,6 +43,19 @@ function safeJson(value) {
43
43
  try { return JSON.stringify(value || {}, null, 2); } catch (_) { return String(value || ''); }
44
44
  }
45
45
 
46
+ function appServerSpawnOptions({ cwd, env, platform = process.platform }) {
47
+ const options = { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] };
48
+
49
+ // Globally installed npm CLIs expose a .cmd shim on Windows. child_process.spawn
50
+ // does not resolve that shim without a shell, causing `spawn codex ENOENT`.
51
+ if (platform === 'win32') {
52
+ options.shell = true;
53
+ options.windowsHide = true;
54
+ }
55
+
56
+ return options;
57
+ }
58
+
46
59
  function textFromInputItems(content) {
47
60
  return (Array.isArray(content) ? content : [])
48
61
  .filter(item => item && item.type === 'text')
@@ -122,6 +135,7 @@ class CodexStructuredSession extends EventEmitter {
122
135
  this.threadId = options.resume || null;
123
136
  this.currentTurnId = null;
124
137
  this.currentTurnStartedAt = null;
138
+ this.tokenUsage = null;
125
139
  this.permissionMode = normalizePermissionMode(options.permissionMode);
126
140
  this.sandboxMode = normalizeSandboxMode(options.sandboxMode);
127
141
  this.effectivePermissionMode = null;
@@ -129,6 +143,10 @@ class CodexStructuredSession extends EventEmitter {
129
143
  this.configPermissionMode = null;
130
144
  this.configSandboxMode = null;
131
145
  this.configSandboxWorkspaceWrite = {};
146
+ this.configModel = null;
147
+ this.configEffort = null;
148
+ this.hasModelOverride = Boolean(options.model);
149
+ this.hasEffortOverride = Boolean(options.effort);
132
150
  this.model = options.model || null;
133
151
  this.effort = options.effort || null;
134
152
  this.models = [];
@@ -217,9 +235,10 @@ class CodexStructuredSession extends EventEmitter {
217
235
  async ensureProcess() {
218
236
  if (this.processReady) return this.processReady;
219
237
  this.processReady = new Promise((resolve, reject) => {
220
- const child = spawn(this.tool.command, ['app-server', '--listen', 'stdio://'], {
221
- cwd: this.workingDir, env: { ...process.env }, stdio: ['pipe', 'pipe', 'pipe']
222
- });
238
+ const child = spawn(this.tool.command, ['app-server', '--listen', 'stdio://'], appServerSpawnOptions({
239
+ cwd: this.workingDir,
240
+ env: { ...process.env }
241
+ }));
223
242
  this.process = child;
224
243
  const fail = error => {
225
244
  this.processReady = null;
@@ -325,6 +344,10 @@ class CodexStructuredSession extends EventEmitter {
325
344
  }
326
345
 
327
346
  handleNotification(method, params) {
347
+ if (method === 'thread/tokenUsage/updated') {
348
+ this.tokenUsage = params.tokenUsage || params.usage || params;
349
+ return;
350
+ }
328
351
  if (method === 'turn/started') {
329
352
  this.currentTurnId = params.turn?.id || params.turnId || this.currentTurnId;
330
353
  this.currentTurnStartedAt = Date.now();
@@ -338,6 +361,10 @@ class CodexStructuredSession extends EventEmitter {
338
361
  : params.turn?.status === 'interrupted' ? 'cancelled' : 'completed';
339
362
  this.append({ kind: 'turn-end', turnId: completedTurnId, status: turnStatus,
340
363
  durationMs: this.currentTurnStartedAt ? Date.now() - this.currentTurnStartedAt : null });
364
+ for (const item of this.messages.filter(message => message.kind === 'tool'
365
+ && message.turnId === completedTurnId && ['running', 'inProgress'].includes(message.toolStatus))) {
366
+ this.patch(item.id, { toolStatus: turnStatus === 'failed' ? 'failed' : 'completed' });
367
+ }
341
368
  for (const pending of this.pendingPermissions.values()) {
342
369
  this.recordPermission(pending.public, 'denied', 'abort');
343
370
  }
@@ -401,10 +428,13 @@ class CodexStructuredSession extends EventEmitter {
401
428
  else this.append({ kind, providerId: itemId, text: delta, streaming: true });
402
429
  return;
403
430
  }
404
- if (method.startsWith('item/')) this.applyProviderItem(params.item || params);
431
+ if (method.startsWith('item/')) {
432
+ const inferredStatus = method === 'item/completed' ? 'completed' : method === 'item/started' ? 'running' : null;
433
+ this.applyProviderItem(params.item || params, inferredStatus);
434
+ }
405
435
  }
406
436
 
407
- applyProviderItem(raw) {
437
+ applyProviderItem(raw, inferredStatus = null) {
408
438
  if (!raw || typeof raw !== 'object') return;
409
439
  const providerId = String(raw.id || '');
410
440
  const existing = providerId && this.messages.find(item => item.providerId === providerId);
@@ -413,8 +443,10 @@ class CodexStructuredSession extends EventEmitter {
413
443
  if (!kind) return;
414
444
  const text = kind === 'user' ? textFromInputItems(raw.content) : kind === 'assistant' ? String(raw.text || '')
415
445
  : 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;
416
448
  const patch = kind === 'tool' ? { ...toolDetails(raw), turnId: raw.turnId || this.currentTurnId,
417
- toolStatus: raw.status || 'running' } : { text, turnId: raw.turnId || this.currentTurnId, streaming: false };
449
+ toolStatus: inferredToolStatus || raw.status || 'running' } : { text, turnId: raw.turnId || this.currentTurnId, streaming: false };
418
450
  if (existing) {
419
451
  this.patch(existing.id, patch);
420
452
  } else if (kind === 'user') {
@@ -432,11 +464,12 @@ class CodexStructuredSession extends EventEmitter {
432
464
  do {
433
465
  const result = await this.request('model/list', { cursor, limit: 100, includeHidden: false });
434
466
  for (const item of result?.data || []) models.push({ id: item.id || item.model, label: item.displayName || item.model || item.id,
435
- efforts: (item.supportedReasoningEfforts || []).map(value => value.reasoningEffort), defaultEffort: item.defaultReasoningEffort || null });
467
+ efforts: (item.supportedReasoningEfforts || []).map(value => value.reasoningEffort), defaultEffort: item.defaultReasoningEffort || null,
468
+ isDefault: Boolean(item.isDefault), contextWindow: Number(item.contextWindow || item.context_window || 0) || null });
436
469
  cursor = result?.nextCursor || null;
437
470
  } while (cursor);
438
471
  this.models = models;
439
- if (!this.model && models[0]) this.model = models[0].id;
472
+ if (!this.model) this.model = (models.find(item => item.isDefault) || models[0])?.id || null;
440
473
  if (!this.effort) this.effort = models.find(item => item.id === this.model)?.defaultEffort || 'medium';
441
474
  this.emitEvent({ type: 'state', state: this.getControlState() });
442
475
  return models;
@@ -448,8 +481,12 @@ class CodexStructuredSession extends EventEmitter {
448
481
  this.configPermissionMode = config.approval_policy || null;
449
482
  this.configSandboxMode = normalizeSandboxMode(config.sandbox_mode);
450
483
  this.configSandboxWorkspaceWrite = config.sandbox_workspace_write || {};
484
+ this.configModel = config.model || null;
485
+ this.configEffort = config.model_reasoning_effort || null;
451
486
  if (!this.permissionMode) this.effectivePermissionMode = this.configPermissionMode;
452
487
  if (!this.sandboxMode) this.effectiveSandboxMode = this.configSandboxMode;
488
+ if (!this.hasModelOverride && this.configModel) this.model = this.configModel;
489
+ if (!this.hasEffortOverride && this.configEffort) this.effort = this.configEffort;
453
490
  this.emitEvent({ type: 'state', state: this.getControlState() });
454
491
  return config;
455
492
  }
@@ -474,11 +511,66 @@ class CodexStructuredSession extends EventEmitter {
474
511
  }));
475
512
  }
476
513
 
514
+ contextStatus() {
515
+ const usage = this.tokenUsage || {};
516
+ const selectedModel = this.models.find(item => item.id === this.model);
517
+ const contextWindow = Number(usage.modelContextWindow || usage.model_context_window
518
+ || usage.contextWindow || usage.context_window || selectedModel?.contextWindow || 0);
519
+ const last = usage.last || usage.lastTokenUsage || usage.last_token_usage || {};
520
+ const usedTokens = Number(last.totalTokens || last.total_tokens || usage.contextTokens
521
+ || usage.context_tokens || 0);
522
+ if (!contextWindow) {
523
+ return !this.threadId && !this.tokenUsage
524
+ ? { usedTokens: 0, contextWindow: null, remainingTokens: null, remainingPercent: 100 }
525
+ : null;
526
+ }
527
+ return {
528
+ usedTokens: Math.max(0, usedTokens),
529
+ contextWindow,
530
+ remainingTokens: Math.max(0, contextWindow - usedTokens),
531
+ remainingPercent: Math.max(0, Math.min(100, Math.round((contextWindow - usedTokens) / contextWindow * 100)))
532
+ };
533
+ }
534
+
535
+ async showStatus() {
536
+ if (this.presentation !== 'structured') return false;
537
+ await this.ensureProcess();
538
+ const accountResult = await this.request('account/read', { refreshToken: false });
539
+ const account = accountResult?.account || null;
540
+ let rateLimits = null;
541
+ if (account?.type === 'chatgpt') {
542
+ try {
543
+ const result = await this.request('account/rateLimits/read', {});
544
+ rateLimits = result?.rateLimits || null;
545
+ } catch (error) {
546
+ this.logger.debugInfo?.(`[codex-app-server] account/rateLimits/read failed: ${error.message}`);
547
+ }
548
+ }
549
+ this.append({ kind: 'status', title: 'Codex status', model: this.model, effort: this.effort,
550
+ account, rateLimits, context: this.contextStatus() });
551
+ return true;
552
+ }
553
+
477
554
  async updateSettings(settings = {}) {
555
+ const configEdits = [];
556
+ if (settings.model) configEdits.push({ keyPath: 'model', value: String(settings.model), mergeStrategy: 'upsert' });
557
+ if (settings.effort) configEdits.push({ keyPath: 'model_reasoning_effort', value: String(settings.effort), mergeStrategy: 'upsert' });
558
+ if (configEdits.length) {
559
+ await this.ensureProcess();
560
+ await this.request('config/batchWrite', { edits: configEdits });
561
+ if (settings.model) this.configModel = String(settings.model);
562
+ if (settings.effort) this.configEffort = String(settings.effort);
563
+ }
478
564
  if (settings.permissionMode !== undefined) this.permissionMode = normalizePermissionMode(settings.permissionMode);
479
565
  if (settings.sandboxMode !== undefined) this.sandboxMode = normalizeSandboxMode(settings.sandboxMode);
480
- if (settings.model !== undefined) this.model = settings.model || null;
481
- if (settings.effort !== undefined) this.effort = settings.effort || null;
566
+ if (settings.model !== undefined) {
567
+ this.hasModelOverride = Boolean(settings.model);
568
+ this.model = settings.model || this.configModel || null;
569
+ }
570
+ if (settings.effort !== undefined) {
571
+ this.hasEffortOverride = Boolean(settings.effort);
572
+ this.effort = settings.effort || this.configEffort || null;
573
+ }
482
574
  const needsConfigDefaults = (settings.permissionMode !== undefined && !this.permissionMode)
483
575
  || (settings.sandboxMode !== undefined && !this.sandboxMode);
484
576
  if (needsConfigDefaults && this.presentation === 'structured') {
@@ -497,22 +589,29 @@ class CodexStructuredSession extends EventEmitter {
497
589
  this.sandboxMode ? {} : this.configSandboxWorkspaceWrite);
498
590
  if (sandboxPolicy) params.sandboxPolicy = sandboxPolicy;
499
591
  }
500
- if (settings.model !== undefined) params.model = this.model;
501
- if (settings.effort !== undefined) params.effort = this.effort;
592
+ if (settings.model !== undefined) params.model = this.hasModelOverride ? this.model : null;
593
+ if (settings.effort !== undefined) params.effort = this.hasEffortOverride ? this.effort : null;
502
594
  if (Object.keys(params).length > 1) await this.request('thread/settings/update', params);
503
595
  }
504
596
  this.emitEvent({ type: 'state', state: this.getControlState() });
505
597
  return this.getControlState();
506
598
  }
507
599
 
508
- async sendUserMessage(text) {
600
+ async sendUserMessage(text, attachments = []) {
509
601
  const prompt = String(text || '').trim();
510
- if (!prompt || this.presentation !== 'structured' || this.status !== 'idle') return false;
602
+ const images = (Array.isArray(attachments) ? attachments : [])
603
+ .filter(item => item && typeof item.path === 'string' && item.path);
604
+ if ((!prompt && images.length === 0) || this.presentation !== 'structured' || this.status !== 'idle') return false;
511
605
  this.hasUnreadCompletion = false;
512
- this.append({ kind: 'user', text: prompt });
606
+ this.append({
607
+ kind: 'user',
608
+ text: prompt || '📷 Image attachment',
609
+ attachments: images.map(item => ({ id: item.id, name: item.name || 'image' }))
610
+ });
513
611
  await this.ensureProcess();
514
612
  if (!this.threadId) {
515
- const params = { model: this.model, cwd: this.workingDir };
613
+ const params = { cwd: this.workingDir };
614
+ if (this.hasModelOverride) params.model = this.model;
516
615
  if (this.permissionMode) params.approvalPolicy = this.permissionMode;
517
616
  if (this.sandboxMode) params.sandbox = this.sandboxMode;
518
617
  const started = await this.request('thread/start', params);
@@ -524,8 +623,12 @@ class CodexStructuredSession extends EventEmitter {
524
623
  this.emitEvent({ type: 'state', state: this.getControlState() });
525
624
  }
526
625
  this.setStatus('running');
527
- const params = { threadId: this.threadId, input: [{ type: 'text', text: prompt }], cwd: this.workingDir,
528
- model: this.model, effort: this.effort, summary: 'auto' };
626
+ const input = [];
627
+ if (prompt) input.push({ type: 'text', text: prompt });
628
+ for (const image of images) input.push({ type: 'localImage', path: image.path });
629
+ const params = { threadId: this.threadId, input, cwd: this.workingDir, summary: 'auto' };
630
+ if (this.hasModelOverride) params.model = this.model;
631
+ if (this.hasEffortOverride) params.effort = this.effort;
529
632
  if (this.permissionMode) params.approvalPolicy = this.permissionMode;
530
633
  const sandboxPolicy = sandboxPolicyFor(this.sandboxMode, this.workingDir);
531
634
  if (sandboxPolicy) params.sandboxPolicy = sandboxPolicy;
@@ -596,22 +699,36 @@ class CodexStructuredSession extends EventEmitter {
596
699
  const target = String(threadId || this.threadId || '').trim();
597
700
  if (!target || this.presentation !== 'structured' || this.status !== 'idle') return false;
598
701
  await this.ensureProcess();
599
- const params = { threadId: target, model: this.model, cwd: this.workingDir };
702
+ const params = { threadId: target, cwd: this.workingDir };
600
703
  if (this.permissionMode) params.approvalPolicy = this.permissionMode;
601
704
  if (this.sandboxMode) params.sandbox = this.sandboxMode;
602
705
  const result = await this.request('thread/resume', params);
603
706
  this.threadId = result.thread?.id || target;
604
- this.model = result.model || this.model;
707
+ this.hasModelOverride = false;
708
+ this.hasEffortOverride = false;
709
+ this.model = result.model || result.thread?.model || this.model;
710
+ this.effort = result.reasoningEffort || result.thread?.reasoningEffort || this.effort;
605
711
  this.effectivePermissionMode = result.approvalPolicy || this.effectivePermissionMode;
606
712
  this.effectiveSandboxMode = sandboxModeFromPolicy(result.sandbox) || this.effectiveSandboxMode;
607
713
  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;
608
717
  this.messages = [];
609
718
  this.completedPermissions = [];
610
719
  for (const turn of history?.thread?.turns || []) {
611
- this.append({ kind: 'turn-start', turnId: turn.id });
612
- for (const item of turn.items || []) this.applyProviderItem({ ...item, turnId: turn.id });
613
720
  const status = turn.status === 'failed' ? 'failed' : turn.status === 'interrupted' ? 'cancelled' : 'completed';
614
- this.append({ kind: 'turn-end', turnId: turn.id, status });
721
+ const startedAt = Number(turn.startedAt || turn.createdAt || 0);
722
+ const completedAt = Number(turn.completedAt || turn.updatedAt || 0);
723
+ const toMilliseconds = value => value > 0 && value < 100000000000 ? value * 1000 : value;
724
+ const startedAtMs = toMilliseconds(startedAt);
725
+ const completedAtMs = toMilliseconds(completedAt);
726
+ this.append({ kind: 'turn-start', turnId: turn.id, ...(startedAtMs ? { createdAt: startedAtMs } : {}) });
727
+ for (const item of turn.items || []) this.applyProviderItem({ ...item, turnId: turn.id }, status === 'failed' ? 'failed' : 'completed');
728
+ const durationMs = Number(turn.durationMs || turn.duration_ms || 0)
729
+ || (startedAtMs && completedAtMs && completedAtMs >= startedAtMs ? completedAtMs - startedAtMs : null);
730
+ this.append({ kind: 'turn-end', turnId: turn.id, status, durationMs,
731
+ ...(completedAtMs ? { createdAt: completedAtMs } : {}) });
615
732
  }
616
733
  this.append({ kind: 'event', level: 'info', text: `Resumed Codex thread ${this.threadId}` });
617
734
  this.emitEvent({ type: 'history-reset', messages: this.messages });
@@ -666,3 +783,4 @@ class CodexStructuredSession extends EventEmitter {
666
783
  }
667
784
 
668
785
  module.exports = CodexStructuredSession;
786
+ module.exports.appServerSpawnOptions = appServerSpawnOptions;
@@ -266,6 +266,51 @@ async function webCommand(options) {
266
266
  res.json({ success: true });
267
267
  });
268
268
 
269
+ // Browser images are stored only in a private, per-session temporary directory.
270
+ // Codex receives the resulting local path through its app-server protocol.
271
+ app.post('/api/sessions/:id/attachments/images', express.raw({ type: () => true, limit: '50mb' }), async (req, res) => {
272
+ try {
273
+ const attachment = await sessionManager.storeCodexImageAttachment(req.params.id, req.body);
274
+ res.status(201).json({ success: true, attachment });
275
+ } catch (e) {
276
+ res.status(e.statusCode || 500).json({ error: e.message });
277
+ }
278
+ });
279
+
280
+ // Mobile Safari can coalesce progress events for a single large request.
281
+ // Small sequential chunks let the browser report progress from server receipts.
282
+ app.post('/api/sessions/:id/attachments/images/chunks', express.raw({ type: () => true, limit: '1mb' }), async (req, res) => {
283
+ try {
284
+ const result = await sessionManager.appendCodexImageChunk(req.params.id, {
285
+ uploadId: req.get('X-Glad-Upload-Id'),
286
+ chunkIndex: req.get('X-Glad-Chunk-Index'),
287
+ chunkTotal: req.get('X-Glad-Chunk-Total')
288
+ }, req.body);
289
+ res.json({ success: true, ...result });
290
+ } catch (e) {
291
+ res.status(e.statusCode || 500).json({ error: e.message });
292
+ }
293
+ });
294
+
295
+ app.delete('/api/sessions/:id/attachments/images/uploads/:uploadId', async (req, res) => {
296
+ try {
297
+ const removed = await sessionManager.discardCodexImageUpload(req.params.id, req.params.uploadId);
298
+ res.json({ success: true, removed });
299
+ } catch (e) {
300
+ res.status(e.statusCode || 500).json({ error: e.message });
301
+ }
302
+ });
303
+
304
+ app.delete('/api/sessions/:id/attachments/images/:attachmentId', async (req, res) => {
305
+ try {
306
+ const removed = await sessionManager.discardCodexImageAttachment(req.params.id, req.params.attachmentId);
307
+ if (!removed) return res.status(404).json({ error: 'Image attachment not found' });
308
+ res.json({ success: true });
309
+ } catch (e) {
310
+ res.status(e.statusCode || 500).json({ error: e.message });
311
+ }
312
+ });
313
+
269
314
  // API: Delete/Kill session
270
315
  app.delete('/api/sessions/:id', (req, res) => {
271
316
  sessionManager.kill(req.params.id);
@@ -514,7 +559,8 @@ async function webCommand(options) {
514
559
  sessionManager.abortClaude(sessionId);
515
560
  }
516
561
  if (payload.type === 'codex-input') {
517
- sessionManager.write(sessionId, payload.text || '');
562
+ sessionManager.sendCodexInput(sessionId, payload.text || '', payload.attachmentIds || [])
563
+ .catch(error => logger.error(`Codex input error: ${error.message}`));
518
564
  }
519
565
  if (payload.type === 'codex-permission') {
520
566
  const codex = sessionManager.get(sessionId);
@@ -525,6 +571,9 @@ async function webCommand(options) {
525
571
  if (payload.type === 'codex-settings') {
526
572
  sessionManager.updateCodexSettings(sessionId, payload.settings || {}).catch(error => logger.error(`Codex settings error: ${error.message}`));
527
573
  }
574
+ if (payload.type === 'codex-status') {
575
+ sessionManager.showCodexStatus(sessionId).catch(error => logger.error(`Codex status error: ${error.message}`));
576
+ }
528
577
  if (payload.type === 'codex-abort') {
529
578
  sessionManager.abortCodex(sessionId);
530
579
  }
@@ -5,7 +5,7 @@ function getTerminalCtor() {
5
5
  if (typeof global.window === 'undefined') {
6
6
  global.window = {};
7
7
  }
8
- ({ Terminal: TerminalCtor } = require('xterm-headless'));
8
+ ({ Terminal: TerminalCtor } = require('@xterm/headless'));
9
9
  return TerminalCtor;
10
10
  }
11
11
 
@@ -21,6 +21,30 @@ function previewText(text, maxChars = 320) {
21
21
  return normalized.length > maxChars ? normalized.slice(-maxChars) : normalized;
22
22
  }
23
23
 
24
+ const CODEX_IMAGE_MAX_BYTES = 50 * 1024 * 1024;
25
+ const CODEX_IMAGE_MAX_PER_SESSION = 5;
26
+ const CODEX_IMAGE_CLEANUP_DELAY_MS = 5 * 60 * 1000;
27
+ const CODEX_IMAGE_MAX_CHUNKS = 128;
28
+
29
+ function detectImageExtension(buffer) {
30
+ if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null;
31
+ if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'png';
32
+ if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'jpg';
33
+ if (buffer.subarray(0, 6).equals(Buffer.from('GIF87a')) || buffer.subarray(0, 6).equals(Buffer.from('GIF89a'))) return 'gif';
34
+ if (buffer.subarray(0, 4).equals(Buffer.from('RIFF')) && buffer.subarray(8, 12).equals(Buffer.from('WEBP'))) return 'webp';
35
+ return null;
36
+ }
37
+
38
+ function attachmentError(message, statusCode = 400) {
39
+ const error = new Error(message);
40
+ error.statusCode = statusCode;
41
+ return error;
42
+ }
43
+
44
+ function isSafeUploadId(value) {
45
+ return typeof value === 'string' && /^[a-zA-Z0-9-]{8,100}$/.test(value);
46
+ }
47
+
24
48
  class SessionManager extends EventEmitter {
25
49
  constructor({ baseDir, renderHistoryTools, debugHistoryEnabled = false, logger, hasConnectedSessionClient } = {}) {
26
50
  super();
@@ -30,6 +54,8 @@ class SessionManager extends EventEmitter {
30
54
  this.logger = logger || console;
31
55
  this.hasConnectedSessionClient = hasConnectedSessionClient || (() => false);
32
56
  this.sessions = new Map();
57
+ this.codexImageRoot = path.join(os.tmpdir(), 'glad', 'codex-images');
58
+ this.codexImageUploadRoot = path.join(os.tmpdir(), 'glad', 'codex-image-uploads');
33
59
  }
34
60
 
35
61
  list() {
@@ -177,6 +203,8 @@ class SessionManager extends EventEmitter {
177
203
  }
178
204
  const id = uuidv4();
179
205
  const session = new CodexStructuredSession({ id, tool, workingDir: sessionDir, name: name || tool.displayName, logger: this.logger, options: codexOptions });
206
+ session.imageAttachments = new Map();
207
+ session.imageUploads = new Map();
180
208
  this.sessions.set(id, session);
181
209
  session.on('event', event => this.emit('codex-event', { sessionId: id, event, session }));
182
210
  session.on('output', data => this.emit('output', { sessionId: id, data, session }));
@@ -203,6 +231,155 @@ class SessionManager extends EventEmitter {
203
231
  return session.sendUserMessage(text);
204
232
  }
205
233
 
234
+ async storeCodexImageAttachment(id, bytes) {
235
+ const session = this.get(id);
236
+ if (!session) throw attachmentError('Session not found', 404);
237
+ if (session.kind !== 'codex-structured' || session.presentation !== 'structured') {
238
+ throw attachmentError('Image attachments are available only in Codex chat mode');
239
+ }
240
+ if (!Buffer.isBuffer(bytes) || bytes.length === 0) throw attachmentError('Image data is required');
241
+ if (bytes.length > CODEX_IMAGE_MAX_BYTES) throw attachmentError('Image must be 50 MB or smaller');
242
+ if (session.imageAttachments.size >= CODEX_IMAGE_MAX_PER_SESSION) {
243
+ throw attachmentError(`You can attach at most ${CODEX_IMAGE_MAX_PER_SESSION} images at a time`);
244
+ }
245
+
246
+ const extension = detectImageExtension(bytes);
247
+ if (!extension) throw attachmentError('Only PNG, JPEG, GIF, and WebP images are supported');
248
+
249
+ const directory = path.join(this.codexImageRoot, session.id);
250
+ await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
251
+ const attachment = {
252
+ id: uuidv4(),
253
+ name: `image.${extension}`,
254
+ path: path.join(directory, `${uuidv4()}.${extension}`),
255
+ size: bytes.length,
256
+ createdAt: Date.now(),
257
+ cleanupTimer: null
258
+ };
259
+ await fs.promises.writeFile(attachment.path, bytes, { mode: 0o600, flag: 'wx' });
260
+ session.imageAttachments.set(attachment.id, attachment);
261
+ return { id: attachment.id, name: attachment.name, size: attachment.size };
262
+ }
263
+
264
+ async appendCodexImageChunk(id, input = {}, bytes) {
265
+ const session = this.get(id);
266
+ if (!session) throw attachmentError('Session not found', 404);
267
+ if (session.kind !== 'codex-structured' || session.presentation !== 'structured') {
268
+ throw attachmentError('Image attachments are available only in Codex chat mode');
269
+ }
270
+ if (!Buffer.isBuffer(bytes) || bytes.length === 0) throw attachmentError('Image chunk is required');
271
+ const uploadId = String(input.uploadId || '');
272
+ const chunkIndex = Number(input.chunkIndex);
273
+ const chunkTotal = Number(input.chunkTotal);
274
+ if (!isSafeUploadId(uploadId)) throw attachmentError('Invalid image upload id');
275
+ if (!Number.isInteger(chunkIndex) || !Number.isInteger(chunkTotal) || chunkIndex < 0 || chunkTotal < 1 || chunkTotal > CODEX_IMAGE_MAX_CHUNKS || chunkIndex >= chunkTotal) {
276
+ throw attachmentError('Invalid image chunk metadata');
277
+ }
278
+
279
+ let upload = session.imageUploads.get(uploadId);
280
+ if (!upload) {
281
+ if (chunkIndex !== 0) throw attachmentError('Image upload must start with the first chunk');
282
+ const directory = path.join(this.codexImageUploadRoot, session.id);
283
+ await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
284
+ upload = {
285
+ id: uploadId,
286
+ path: path.join(directory, `${uploadId}.part`),
287
+ chunkTotal,
288
+ nextChunkIndex: 0,
289
+ bytes: 0
290
+ };
291
+ session.imageUploads.set(uploadId, upload);
292
+ }
293
+ if (upload.chunkTotal !== chunkTotal || upload.nextChunkIndex !== chunkIndex) {
294
+ throw attachmentError('Image chunks arrived out of order');
295
+ }
296
+ if (upload.bytes + bytes.length > CODEX_IMAGE_MAX_BYTES) {
297
+ await this.discardCodexImageUpload(id, uploadId);
298
+ throw attachmentError('Image must be 50 MB or smaller');
299
+ }
300
+
301
+ if (chunkIndex === 0) await fs.promises.writeFile(upload.path, bytes, { mode: 0o600, flag: 'wx' });
302
+ else await fs.promises.appendFile(upload.path, bytes, { mode: 0o600 });
303
+ upload.bytes += bytes.length;
304
+ upload.nextChunkIndex += 1;
305
+
306
+ if (upload.nextChunkIndex < upload.chunkTotal) {
307
+ return { complete: false, receivedChunks: upload.nextChunkIndex, size: upload.bytes };
308
+ }
309
+
310
+ session.imageUploads.delete(uploadId);
311
+ try {
312
+ const image = await fs.promises.readFile(upload.path);
313
+ const attachment = await this.storeCodexImageAttachment(id, image);
314
+ return { complete: true, attachment };
315
+ } finally {
316
+ await fs.promises.rm(upload.path, { force: true });
317
+ }
318
+ }
319
+
320
+ async discardCodexImageUpload(id, uploadId) {
321
+ const session = this.get(id);
322
+ if (!session || !session.imageUploads || !isSafeUploadId(uploadId)) return false;
323
+ const upload = session.imageUploads.get(uploadId);
324
+ if (!upload) return false;
325
+ session.imageUploads.delete(uploadId);
326
+ await fs.promises.rm(upload.path, { force: true });
327
+ return true;
328
+ }
329
+
330
+ async discardCodexImageAttachment(id, attachmentId) {
331
+ const session = this.get(id);
332
+ if (!session || session.kind !== 'codex-structured') return false;
333
+ const attachment = session.imageAttachments.get(attachmentId);
334
+ if (!attachment) return false;
335
+ clearTimeout(attachment.cleanupTimer);
336
+ session.imageAttachments.delete(attachmentId);
337
+ await fs.promises.rm(attachment.path, { force: true });
338
+ return true;
339
+ }
340
+
341
+ getCodexImageAttachments(id, attachmentIds = []) {
342
+ const session = this.get(id);
343
+ if (!session || session.kind !== 'codex-structured') throw attachmentError('Codex session not found', 404);
344
+ const ids = Array.isArray(attachmentIds) ? attachmentIds : [];
345
+ if (ids.length > CODEX_IMAGE_MAX_PER_SESSION) throw attachmentError(`You can attach at most ${CODEX_IMAGE_MAX_PER_SESSION} images at a time`);
346
+ const uniqueIds = [...new Set(ids.map(value => String(value)))];
347
+ if (uniqueIds.length !== ids.length) throw attachmentError('Duplicate image attachment');
348
+ return uniqueIds.map(attachmentId => {
349
+ const attachment = session.imageAttachments.get(attachmentId);
350
+ if (!attachment) throw attachmentError('Image attachment is no longer available');
351
+ return attachment;
352
+ });
353
+ }
354
+
355
+ scheduleCodexImageCleanup(id, attachmentIds) {
356
+ const session = this.get(id);
357
+ if (!session || session.kind !== 'codex-structured') return;
358
+ for (const attachmentId of attachmentIds) {
359
+ const attachment = session.imageAttachments.get(attachmentId);
360
+ if (!attachment) continue;
361
+ clearTimeout(attachment.cleanupTimer);
362
+ attachment.cleanupTimer = setTimeout(() => {
363
+ this.discardCodexImageAttachment(id, attachmentId).catch(error => {
364
+ this.logger.debugInfo?.(`[codex-image] cleanup failed: ${error.message}`);
365
+ });
366
+ }, CODEX_IMAGE_CLEANUP_DELAY_MS);
367
+ attachment.cleanupTimer.unref?.();
368
+ }
369
+ }
370
+
371
+ async sendCodexInput(id, text, attachmentIds = []) {
372
+ const session = this.get(id);
373
+ if (!session || session.kind !== 'codex-structured') return false;
374
+ const attachments = this.getCodexImageAttachments(id, attachmentIds);
375
+ const prompt = String(text || '');
376
+ if (!prompt.trim() && attachments.length === 0) return false;
377
+ this.markSessionInput(session, prompt || '[image attachment]');
378
+ const sent = await session.sendUserMessage(prompt, attachments);
379
+ if (sent && attachments.length) this.scheduleCodexImageCleanup(id, attachments.map(item => item.id));
380
+ return sent;
381
+ }
382
+
206
383
  respondClaudePermission(id, permissionId, approved, action = null) {
207
384
  const session = this.get(id);
208
385
  if (!session || session.kind !== 'claude-structured') return false;
@@ -245,6 +422,12 @@ class SessionManager extends EventEmitter {
245
422
  return session.updateSettings(settings || {});
246
423
  }
247
424
 
425
+ showCodexStatus(id) {
426
+ const session = this.get(id);
427
+ if (!session || session.kind !== 'codex-structured') return Promise.resolve(false);
428
+ return session.showStatus();
429
+ }
430
+
248
431
  abortCodex(id) {
249
432
  const session = this.get(id);
250
433
  return session && session.kind === 'codex-structured' ? session.abort('Aborted by user') : false;
@@ -432,6 +615,8 @@ class SessionManager extends EventEmitter {
432
615
  if (!session) return false;
433
616
  clearTimeout(session.completionTimer);
434
617
  this.clearTimedInputs(session);
618
+ this.clearCodexImageUploads(session);
619
+ this.clearCodexImageAttachments(session);
435
620
  this.logSessionDiagnostics('session-deleted', session, {}, { compact: true });
436
621
  this.disposeSessionHistory(session);
437
622
  if (['claude-structured', 'codex-structured'].includes(session.kind)) {
@@ -448,6 +633,8 @@ class SessionManager extends EventEmitter {
448
633
  for (const session of this.sessions.values()) {
449
634
  clearTimeout(session.completionTimer);
450
635
  this.clearTimedInputs(session);
636
+ this.clearCodexImageUploads(session);
637
+ this.clearCodexImageAttachments(session);
451
638
  this.disposeSessionHistory(session);
452
639
  session.ptyManager.kill();
453
640
  }
@@ -796,6 +983,8 @@ class SessionManager extends EventEmitter {
796
983
  this.logger.info(`Session ${session.id} (${session.name}) exited.`);
797
984
  clearTimeout(session.completionTimer);
798
985
  this.clearTimedInputs(session);
986
+ this.clearCodexImageUploads(session);
987
+ this.clearCodexImageAttachments(session);
799
988
  this.disposeSessionHistory(session);
800
989
  this.sessions.delete(session.id);
801
990
  this.emit('exit', { sessionId: session.id, session });
@@ -838,6 +1027,23 @@ class SessionManager extends EventEmitter {
838
1027
  session.timedInputs.clear();
839
1028
  }
840
1029
 
1030
+ clearCodexImageAttachments(session) {
1031
+ if (!session || !session.imageAttachments) return;
1032
+ for (const attachment of session.imageAttachments.values()) clearTimeout(attachment.cleanupTimer);
1033
+ session.imageAttachments.clear();
1034
+ fs.promises.rm(path.join(this.codexImageRoot, session.id), { recursive: true, force: true }).catch(error => {
1035
+ this.logger.debugInfo?.(`[codex-image] session cleanup failed: ${error.message}`);
1036
+ });
1037
+ }
1038
+
1039
+ clearCodexImageUploads(session) {
1040
+ if (!session || !session.imageUploads) return;
1041
+ session.imageUploads.clear();
1042
+ fs.promises.rm(path.join(this.codexImageUploadRoot, session.id), { recursive: true, force: true }).catch(error => {
1043
+ this.logger.debugInfo?.(`[codex-image] upload cleanup failed: ${error.message}`);
1044
+ });
1045
+ }
1046
+
841
1047
  getSessionDiagnostics(session, extra = {}) {
842
1048
  if (['claude-structured', 'codex-structured'].includes(session.kind)) {
843
1049
  return {
@@ -52,9 +52,26 @@
52
52
  #cmd-input { flex: 1; min-height: 38px; max-height: 150px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); border-radius: 19px; color: #fff; padding: 9px 16px; font-size: 16px; outline: none; resize: none; overflow-y: auto; line-height: 20px; box-sizing: border-box; transition: background 0.18s ease, border-color 0.18s ease; }
53
53
  #cmd-input::placeholder { color: rgba(255,255,255,0.45); }
54
54
  #cmd-input:focus { background: rgba(255,255,255,0.1); border-color: rgba(0,122,255,0.42); color: #fff; }
55
- #timer-btn { width: 38px; height: 38px; margin-left: 8px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); border-radius: 19px; color: #d1d5db; display: flex; align-items: center; justify-content: center; flex-shrink: 0; cursor: pointer; }
55
+ #timer-btn { width: 38px; height: 38px; margin-left: 8px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); border-radius: 19px; color: #d1d5db; display: flex; align-items: center; justify-content: center; flex-shrink: 0; cursor: pointer; font-size: 24px; line-height: 1; }
56
56
  #timer-btn.active { color: #fff; border-color: rgba(0,122,255,0.45); background: rgba(0,122,255,0.22); }
57
57
  #send-btn { width: 44px; height: 38px; margin-left: 10px; background: #007aff; border: none; border-radius: 19px; color: #fff; display: flex; align-items: center; justify-content: center; flex-shrink: 0; cursor: pointer; }
58
+ #composer-menu { display: none; width: min(calc(100% - 28px), var(--control-content-max)); margin: -2px auto 8px auto; padding: 6px; border: 1px solid rgba(255,255,255,0.12); border-radius: 12px; background: rgba(38,38,42,0.98); box-shadow: 0 12px 28px rgba(0,0,0,0.28); box-sizing: border-box; }
59
+ #composer-menu.active { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
60
+ .composer-menu-btn { min-height: 42px; border: 0; border-radius: 9px; background: rgba(255,255,255,0.08); color: #fff; font-size: 14px; font-weight: 650; cursor: pointer; }
61
+ .composer-menu-btn:hover { background: rgba(255,255,255,0.14); }
62
+ #attachment-strip { display: none; width: min(calc(100% - 28px), var(--control-content-max)); margin: -3px auto 2px auto; gap: 7px; overflow-x: auto; padding: 0 0 4px 0; box-sizing: border-box; scrollbar-width: none; }
63
+ #attachment-strip.active { display: flex; }
64
+ #attachment-strip::-webkit-scrollbar { display: none; }
65
+ .attachment-chip { min-width: 0; max-width: 210px; display: flex; align-items: center; gap: 7px; padding: 7px 9px; border: 1px solid rgba(0,122,255,0.45); border-radius: 10px; background: rgba(0,122,255,0.14); color: #e9f2ff; font-size: 12px; }
66
+ .attachment-chip.uploading { border-color: rgba(255,159,10,0.55); background: rgba(255,159,10,0.12); }
67
+ .attachment-chip-content { min-width: 0; flex: 1; }
68
+ .attachment-chip-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
69
+ .attachment-progress { display: block; height: 5px; margin-top: 5px; overflow: hidden; border-radius: 999px; background: rgba(255,255,255,0.16); }
70
+ .attachment-progress > span { display: block; height: 100%; border-radius: inherit; background: #ff9f0a; transition: width .15s ease; }
71
+ .attachment-progress.estimated > span { background: repeating-linear-gradient(90deg, #ff9f0a 0 12px, #ffd37b 12px 24px); background-size: 48px 100%; animation: attachment-upload-pulse .75s linear infinite; }
72
+ .attachment-status { display: block; margin-top: 3px; color: #ffd59a; font-size: 10px; }
73
+ @keyframes attachment-upload-pulse { from { background-position: 0 0; } to { background-position: 48px 0; } }
74
+ .attachment-remove { width: 19px; height: 19px; padding: 0; border: 0; border-radius: 50%; background: rgba(255,255,255,0.16); color: #fff; font-size: 15px; line-height: 18px; cursor: pointer; flex: 0 0 auto; }
58
75
  #timed-send-panel { display: none; margin: 0 14px 10px 14px; padding: 12px; border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; background: rgba(28,28,30,0.98); box-sizing: border-box; }
59
76
  #timed-send-panel.active { display: block; }
60
77
  .timed-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; align-items: center; }
@@ -80,9 +97,17 @@
80
97
  #claude-chat-container { display: none; flex: 1; min-height: 0; overflow-y: auto; background: #050505; padding: 12px 12px 24px 12px; box-sizing: border-box; -webkit-overflow-scrolling: touch; }
81
98
  #codex-chat-container { display: none; flex: 1; min-height: 0; overflow-y: auto; background: #050505; padding: 12px 12px 24px 12px; box-sizing: border-box; -webkit-overflow-scrolling: touch; }
82
99
  .codex-conversation { width: min(100%, var(--chat-content-max)); margin: 0 auto; }
100
+ .codex-working-indicator { position: sticky; top: 0; z-index: 4; width: 28px; height: 28px; margin: 0 0 -28px auto; border: 1px solid rgba(255,255,255,.1); border-radius: 50%; background: rgba(28,28,30,.68); box-shadow: 0 5px 16px rgba(0,0,0,.24); backdrop-filter: blur(8px); pointer-events: none; }
101
+ .codex-working-indicator::after { content: ''; position: absolute; inset: 8px; border: 1.5px solid rgba(255,255,255,.7); border-top-color: transparent; border-radius: 50%; animation: codex-spin .8s linear infinite; }
83
102
  .codex-message { max-width: 100%; margin: 0 0 12px; color: #f5f5f7; font-size: 14px; line-height: 1.45; overflow-wrap: anywhere; }
84
103
  .codex-message.user { max-width: 92%; margin-left: auto; padding: 8px 12px; border: 1px solid rgba(0,122,255,.32); border-radius: 12px; background: rgba(0,122,255,.24); }
85
104
  .codex-message.event { color: var(--text-dim); text-align: center; font-size: 12px; }
105
+ .codex-status-card { margin: 10px 0 14px; border: 1px solid rgba(100,210,255,.2); border-radius: 10px; background: rgba(28,28,30,.72); padding: 11px; color: #f5f5f7; }
106
+ .codex-status-title { margin-bottom: 9px; color: #64d2ff; font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; }
107
+ .codex-status-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; }
108
+ .codex-status-item { min-width: 0; border: 1px solid rgba(255,255,255,.07); border-radius: 8px; background: rgba(255,255,255,.035); padding: 8px; }
109
+ .codex-status-label { color: #8e8e93; font-size: 10px; font-weight: 800; text-transform: uppercase; }
110
+ .codex-status-value { margin-top: 3px; color: #f5f5f7; font-size: 12px; font-weight: 700; overflow-wrap: anywhere; }
86
111
  .codex-tool { border: 1px solid rgba(255,255,255,.1); border-radius: 8px; background: rgba(255,255,255,.045); overflow: hidden; margin: 8px 0; color: #d1d5db; }
87
112
  .codex-tool summary { list-style: none; cursor: pointer; }
88
113
  .codex-tool summary::-webkit-details-marker { display: none; }
@@ -117,8 +142,9 @@
117
142
  .codex-inline-permission .claude-permission-actions { margin-top: 8px; }
118
143
  @keyframes codex-spin { to { transform: rotate(360deg); } }
119
144
  #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; }
120
- .codex-control-row { display: grid; grid-template-columns: repeat(5, minmax(0, 116px)); justify-content: center; gap: 7px; align-items: center; }
121
- .codex-control-row > * { width: 100%; }
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; }
122
148
  .codex-select-control { position: relative; display: block; min-width: 0; height: 32px; }
123
149
  .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; }
124
150
  .codex-select-control:focus-within .codex-select-label { background: rgba(255,255,255,.14); border-color: rgba(0,122,255,.45); }
@@ -293,7 +319,7 @@
293
319
  #nav-bar > div:last-child .icon-btn { min-width: 34px; min-height: 30px; padding: 5px 7px !important; }
294
320
  #input-row { padding-left: 10px; padding-right: 10px; }
295
321
  #codex-control-panel { padding-left: 8px; padding-right: 8px; }
296
- .codex-control-row { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 5px; }
322
+ .codex-control-row { grid-template-columns: repeat(6, minmax(0, 1fr)); }
297
323
  .codex-control-row .claude-ctrl-btn { height: 36px; padding-left: 4px; padding-right: 4px; }
298
324
  .codex-select-control { height: 36px; }
299
325
  #codex-chat-container { padding: 10px 10px calc(22px + env(safe-area-inset-bottom)); }
@@ -367,13 +393,17 @@
367
393
  <div id="timed-tag-rail"></div>
368
394
  <div id="input-row">
369
395
  <textarea id="cmd-input" rows="1" placeholder="Type a message..."></textarea>
370
- <button id="timer-btn" title="Schedule send">
371
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>
372
- </button>
396
+ <button id="timer-btn" title="Add image or schedule send" aria-label="Add image or schedule send">+</button>
373
397
  <button id="send-btn">
374
398
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
375
399
  </button>
376
400
  </div>
401
+ <div id="attachment-strip" aria-live="polite"></div>
402
+ <div id="composer-menu" role="menu">
403
+ <button id="attach-image-btn" class="composer-menu-btn" type="button" role="menuitem">Add image</button>
404
+ <button id="schedule-send-btn" class="composer-menu-btn" type="button" role="menuitem">Schedule send</button>
405
+ </div>
406
+ <input id="image-file-input" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden>
377
407
  <div id="claude-control-panel">
378
408
  <div class="claude-control-row">
379
409
  <div class="claude-picker-wrap">
@@ -425,6 +455,7 @@
425
455
  </select>
426
456
  </label>
427
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>
428
459
  <button id="codex-abort-btn" class="claude-ctrl-btn danger" onclick="abortCodexSession()" title="Abort current Codex turn">Abort</button>
429
460
  <button id="codex-resume-btn" class="claude-ctrl-btn primary" onclick="toggleCodexResumePanel()" title="Resume a Codex thread">Resume</button>
430
461
  </div>
@@ -932,6 +963,7 @@
932
963
  document.getElementById('codex-control-panel').style.display = codexChat ? 'block' : 'none';
933
964
  document.getElementById('codex-terminal-switch').style.display = isCodexSession() ? '' : 'none';
934
965
  document.getElementById('timer-btn').style.display = '';
966
+ document.getElementById('attach-image-btn').style.display = codexChat ? '' : 'none';
935
967
  document.getElementById('shortcut-rail').style.display = structured ? 'none' : '';
936
968
  document.getElementById('scroll-controls').style.display = structured ? 'none' : '';
937
969
  document.getElementById('cmd-input').placeholder = enabled ? 'Message Claude...' : codexChat ? 'Message Codex...' : 'Type a message...';
@@ -2078,6 +2110,39 @@
2078
2110
  if (inline) return `<div class="codex-inline-permission">${content}</div>`;
2079
2111
  return `<div class="claude-tool claude-permission"><div class="claude-tool-header"><strong>${escapeHtml(request.title || 'Permission required')}</strong></div><div class="claude-tool-body">${content}</div></div>`;
2080
2112
  }
2113
+ function formatCodexReset(timestamp) {
2114
+ const value = Number(timestamp || 0);
2115
+ return value ? new Date(value * 1000).toLocaleString() : '';
2116
+ }
2117
+ function formatCodexTokens(value) {
2118
+ const count = Number(value || 0);
2119
+ return count >= 1000000 ? `${(count / 1000000).toFixed(1)}M`
2120
+ : count >= 1000 ? `${Math.round(count / 1000)}K` : String(count);
2121
+ }
2122
+ function codexStatusItem(label, value) {
2123
+ if (value == null || value === '') return '';
2124
+ return `<div class="codex-status-item"><div class="codex-status-label">${escapeHtml(label)}</div><div class="codex-status-value">${escapeHtml(value)}</div></div>`;
2125
+ }
2126
+ function renderCodexStatus(item) {
2127
+ const account = item.account || {};
2128
+ const limit = item.rateLimits || {};
2129
+ const primary = limit.primary;
2130
+ const secondary = limit.secondary;
2131
+ const context = item.context;
2132
+ const accountLabel = account.type === 'chatgpt'
2133
+ ? [account.email, account.planType].filter(Boolean).join(' · ')
2134
+ : account.type === 'apiKey' ? 'API key' : account.type || 'Not signed in';
2135
+ const fiveHour = primary ? `${Math.max(0, 100 - Number(primary.usedPercent || 0))}% left${primary.resetsAt ? ` · resets ${formatCodexReset(primary.resetsAt)}` : ''}` : '';
2136
+ const weekly = secondary ? `${Math.max(0, 100 - Number(secondary.usedPercent || 0))}% left${secondary.resetsAt ? ` · resets ${formatCodexReset(secondary.resetsAt)}` : ''}` : '';
2137
+ const contextLabel = context ? `${context.remainingPercent}% left${context.contextWindow ? ` · ${formatCodexTokens(context.remainingTokens)} / ${formatCodexTokens(context.contextWindow)}` : ''}` : 'Available after the first usage update';
2138
+ return `<div class="codex-status-card"><div class="codex-status-title">${escapeHtml(item.title || 'Codex status')}</div><div class="codex-status-grid">
2139
+ ${codexStatusItem('Account', accountLabel)}
2140
+ ${codexStatusItem('Model', [item.model, item.effort].filter(Boolean).join(' · '))}
2141
+ ${codexStatusItem('5h limit', fiveHour)}
2142
+ ${codexStatusItem('Weekly limit', weekly)}
2143
+ ${codexStatusItem('Context', contextLabel)}
2144
+ </div></div>`;
2145
+ }
2081
2146
  function renderCodexTool(item, permission = null) {
2082
2147
  const status = codexToolStatus(item);
2083
2148
  const isError = status === 'failed' || Boolean(item.error) || (item.exitCode != null && item.exitCode !== 0);
@@ -2091,17 +2156,22 @@
2091
2156
  const result = item.result || (item.name === 'McpTool' || item.name === 'Agent' ? codexJson(item.input) : '');
2092
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>`;
2093
2158
  }
2094
- function renderCodexToolGroup(items, permissionById, usedPermissions) {
2159
+ function renderCodexToolGroup(items, permissionById, usedPermissions, turnEnd = null) {
2095
2160
  const running = items.some(item => codexToolStatus(item) === 'running');
2096
2161
  const failed = items.some(item => codexToolStatus(item) === 'failed' || item.error);
2097
2162
  const startedAt = Math.min(...items.map(item => Number(item.createdAt || Date.now())));
2098
- const seconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
2163
+ const storedDuration = Number(turnEnd?.durationMs || 0);
2164
+ const completedAt = Number(turnEnd?.createdAt || 0);
2165
+ const durationMs = storedDuration > 0 ? storedDuration
2166
+ : (!running && completedAt >= startedAt ? completedAt - startedAt : 0);
2167
+ const seconds = durationMs > 0 ? Math.max(1, Math.round(durationMs / 1000)) : null;
2099
2168
  const tools = items.map(item => {
2100
2169
  const permission = permissionById.get(String(item.providerId || ''));
2101
2170
  if (permission) usedPermissions.add(permission.id);
2102
2171
  return renderCodexTool(item, permission);
2103
2172
  }).join('');
2104
- return `<details class="codex-work-group"${running ? ' open' : ''}><summary>${running ? 'Working' : failed ? 'Work finished with errors' : `Worked for ${seconds}s`} · ${items.length} tools</summary><div class="codex-work-group-body">${tools}</div></details>`;
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>`;
2105
2175
  }
2106
2176
  function renderCodexChat() {
2107
2177
  const container = document.getElementById('codex-chat-container');
@@ -2109,13 +2179,17 @@
2109
2179
  const parts = [];
2110
2180
  const permissionById = new Map(codexPendingPermissions.map(item => [String(item.id), item]));
2111
2181
  const usedPermissions = new Set();
2182
+ const turnEndById = new Map(codexMessages.filter(item => item.kind === 'turn-end' && item.turnId)
2183
+ .map(item => [String(item.turnId), item]));
2112
2184
  const visible = codexMessages.filter(item => !['reasoning', 'turn-start', 'turn-end'].includes(item.kind));
2113
2185
  for (let i = 0; i < visible.length;) {
2114
2186
  const item = visible[i];
2115
2187
  if (item.kind === 'tool') {
2116
2188
  const tools = [];
2117
- while (i < visible.length && visible[i].kind === 'tool') tools.push(visible[i++]);
2118
- if (tools.length > 1) parts.push(renderCodexToolGroup(tools, permissionById, usedPermissions));
2189
+ const turnId = item.turnId;
2190
+ while (i < visible.length && visible[i].kind === 'tool' && visible[i].turnId === turnId) tools.push(visible[i++]);
2191
+ if (tools.length > 1) parts.push(renderCodexToolGroup(tools, permissionById, usedPermissions,
2192
+ turnEndById.get(String(turnId || ''))));
2119
2193
  else {
2120
2194
  const permission = permissionById.get(String(tools[0].providerId || ''));
2121
2195
  if (permission) usedPermissions.add(permission.id);
@@ -2125,14 +2199,14 @@
2125
2199
  }
2126
2200
  if (item.kind === 'assistant') parts.push(`<div class="codex-message assistant claude-md">${renderMarkdown(item.text || '')}</div>`);
2127
2201
  else if (item.kind === 'user') parts.push(`<div class="codex-message user claude-md">${renderMarkdown(item.text || '')}</div>`);
2202
+ else if (item.kind === 'status') parts.push(renderCodexStatus(item));
2128
2203
  else parts.push(`<div class="codex-message ${escapeHtml(item.level === 'error' ? 'event error' : item.kind || 'event')}">${codexText(item.text)}</div>`);
2129
2204
  i += 1;
2130
2205
  }
2131
2206
  for (const request of codexPendingPermissions) if (!usedPermissions.has(request.id)) parts.push(renderCodexPermission(request));
2132
- if (codexState.status === 'running') {
2133
- parts.push('<div class="claude-status">Codex is working...</div>');
2134
- }
2135
- container.innerHTML = `<div class="codex-conversation">${parts.join('') || '<div class="codex-message event">Send a message to start Codex.</div>'}</div>`;
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>`;
2136
2210
  requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
2137
2211
  }
2138
2212
 
@@ -2175,7 +2249,6 @@
2175
2249
  const pending = Number(codexState.pendingPermissionCount || codexPendingPermissions.filter(item => item.status === 'pending').length) || 0;
2176
2250
  const parts = [];
2177
2251
  if (pending || codexState.status === 'waiting_approval') parts.push(`<span class="claude-state-pill warn">${pending || 1} approval${pending === 1 ? '' : 's'}</span>`);
2178
- else if (codexState.status === 'running') parts.push('<span class="claude-state-pill">Working</span>');
2179
2252
  el.innerHTML = parts.join('');
2180
2253
  el.style.display = parts.length ? 'flex' : 'none';
2181
2254
  }
@@ -2228,6 +2301,7 @@
2228
2301
  sendCodexSettings({ permissionMode, sandboxMode });
2229
2302
  }
2230
2303
  function abortCodexSession() { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-abort' })); }
2304
+ function requestCodexStatus() { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-status' })); }
2231
2305
  function respondCodexPermission(id, decision) { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-permission', id, decision })); }
2232
2306
  async function toggleCodexResumePanel() {
2233
2307
  codexResumePanelOpen = !codexResumePanelOpen;
@@ -2260,6 +2334,9 @@
2260
2334
  }
2261
2335
 
2262
2336
  function joinSession(id, sessionName, toolKey = null) {
2337
+ if (typeof clearImageAttachments === 'function' && selectedImageAttachments.length) {
2338
+ void clearImageAttachments();
2339
+ }
2263
2340
  stopTimedInputTimers();
2264
2341
  activeSessionId = id;
2265
2342
  window.activeSessionId = id;
@@ -2603,8 +2680,177 @@
2603
2680
  }
2604
2681
 
2605
2682
  const inputEl = document.getElementById('cmd-input');
2683
+ const imageFileInput = document.getElementById('image-file-input');
2684
+ const attachmentStrip = document.getElementById('attachment-strip');
2685
+ let selectedImageAttachments = [];
2606
2686
  let keepTerminalBottomForNextInput = false;
2607
2687
 
2688
+ function isCodexImageAttachmentAvailable() {
2689
+ return isCodexSession() && codexState.presentation === 'structured';
2690
+ }
2691
+
2692
+ function syncComposerButtonState() {
2693
+ const menuOpen = document.getElementById('composer-menu').classList.contains('active');
2694
+ const timerOpen = document.getElementById('timed-send-panel').classList.contains('active');
2695
+ document.getElementById('timer-btn').classList.toggle('active', menuOpen || timerOpen);
2696
+ }
2697
+
2698
+ function renderImageAttachments() {
2699
+ attachmentStrip.innerHTML = selectedImageAttachments.map(item => (
2700
+ `<div class="attachment-chip${item.uploading ? ' uploading' : ''}"><span aria-hidden="true">▧</span><span class="attachment-chip-content"><span class="attachment-chip-name" title="${escapeHtml(item.name)}">${escapeHtml(item.name)}</span>${item.uploading ? `<span class="attachment-progress${item.progressKnown ? '' : ' estimated'}"><span style="width:${Math.max(0, Math.min(100, item.progress || 0))}%"></span></span><span class="attachment-status">${escapeHtml(item.status || (item.progressKnown ? `Uploading ${Math.round(item.progress || 0)}%` : 'Uploading original image…'))}</span>` : ''}</span><button class="attachment-remove" type="button" title="Remove image" aria-label="Remove ${escapeHtml(item.name)}" onclick="removeImageAttachment('${item.id}')">×</button></div>`
2701
+ )).join('');
2702
+ attachmentStrip.classList.toggle('active', selectedImageAttachments.length > 0);
2703
+ updateTerminalControlsHeight();
2704
+ }
2705
+
2706
+ window.removeImageAttachment = async function(attachmentId) {
2707
+ const attachment = selectedImageAttachments.find(item => item.id === attachmentId);
2708
+ selectedImageAttachments = selectedImageAttachments.filter(item => item.id !== attachmentId);
2709
+ renderImageAttachments();
2710
+ if (!attachment) return;
2711
+ clearInterval(attachment.indicatorTimer);
2712
+ attachment.abortUpload?.();
2713
+ if (attachment.uploading) return;
2714
+ try {
2715
+ await fetchWithTimeout(`/api/sessions/${attachment.sessionId}/attachments/images/${encodeURIComponent(attachment.id)}`, { method: 'DELETE' });
2716
+ } catch (_) {
2717
+ // The server also removes all attachments when the session ends.
2718
+ }
2719
+ };
2720
+
2721
+ async function clearImageAttachments() {
2722
+ const pending = selectedImageAttachments;
2723
+ selectedImageAttachments = [];
2724
+ renderImageAttachments();
2725
+ for (const item of pending) item.abortUpload?.();
2726
+ await Promise.all(pending.filter(item => !item.uploading).map(item => fetchWithTimeout(
2727
+ `/api/sessions/${item.sessionId}/attachments/images/${encodeURIComponent(item.id)}`,
2728
+ { method: 'DELETE' }
2729
+ ).catch(() => null)));
2730
+ }
2731
+
2732
+ const IMAGE_UPLOAD_CHUNK_BYTES = 512 * 1024;
2733
+
2734
+ function uploadImageInChunks(sessionId, file, onProgress) {
2735
+ let xhr = null;
2736
+ let cancelled = false;
2737
+ const uploadId = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
2738
+ const chunkTotal = Math.ceil(file.size / IMAGE_UPLOAD_CHUNK_BYTES);
2739
+ return {
2740
+ abort: () => {
2741
+ cancelled = true;
2742
+ xhr?.abort();
2743
+ void fetch(`/api/sessions/${sessionId}/attachments/images/uploads/${encodeURIComponent(uploadId)}`, { method: 'DELETE' }).catch(() => null);
2744
+ },
2745
+ promise: (async () => {
2746
+ for (let chunkIndex = 0; chunkIndex < chunkTotal; chunkIndex++) {
2747
+ if (cancelled) throw new Error('Image upload cancelled');
2748
+ const start = chunkIndex * IMAGE_UPLOAD_CHUNK_BYTES;
2749
+ const chunk = file.slice(start, Math.min(file.size, start + IMAGE_UPLOAD_CHUNK_BYTES));
2750
+ const result = await new Promise((resolve, reject) => {
2751
+ xhr = new XMLHttpRequest();
2752
+ xhr.open('POST', `/api/sessions/${sessionId}/attachments/images/chunks`);
2753
+ xhr.timeout = 60_000;
2754
+ xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
2755
+ xhr.setRequestHeader('X-Glad-Upload-Id', uploadId);
2756
+ xhr.setRequestHeader('X-Glad-Chunk-Index', String(chunkIndex));
2757
+ xhr.setRequestHeader('X-Glad-Chunk-Total', String(chunkTotal));
2758
+ xhr.onerror = () => reject(new Error('Network error while uploading image'));
2759
+ xhr.ontimeout = () => reject(new Error(`Image upload timed out on chunk ${chunkIndex + 1}/${chunkTotal}`));
2760
+ xhr.onabort = () => reject(new Error('Image upload cancelled'));
2761
+ xhr.onload = () => {
2762
+ let data = {};
2763
+ try { data = JSON.parse(xhr.responseText || '{}'); } catch (_) {}
2764
+ if (xhr.status < 200 || xhr.status >= 300 || !data.success) {
2765
+ reject(new Error(data.error || `Upload failed (HTTP ${xhr.status})`));
2766
+ return;
2767
+ }
2768
+ resolve(data);
2769
+ };
2770
+ xhr.send(chunk);
2771
+ });
2772
+ const confirmedBytes = Math.min(file.size, start + chunk.size);
2773
+ onProgress(Math.round((confirmedBytes / file.size) * 100), chunkIndex + 1, chunkTotal);
2774
+ if (result.complete) return result.attachment;
2775
+ }
2776
+ throw new Error('Image upload did not complete');
2777
+ })()
2778
+ };
2779
+ }
2780
+
2781
+ async function uploadImageFiles(files) {
2782
+ if (!isCodexImageAttachmentAvailable()) {
2783
+ alert('Image attachments are available only in Codex chat mode.');
2784
+ return;
2785
+ }
2786
+ const remaining = 5 - selectedImageAttachments.length;
2787
+ const batch = Array.from(files).slice(0, remaining);
2788
+ if (files.length > remaining) alert('You can attach up to 5 images at a time.');
2789
+ for (const file of batch) {
2790
+ if (file.size > 50 * 1024 * 1024) {
2791
+ alert(`${file.name} is larger than 50 MB.`);
2792
+ continue;
2793
+ }
2794
+ const chunkTotal = Math.ceil(file.size / IMAGE_UPLOAD_CHUNK_BYTES);
2795
+ const pending = {
2796
+ id: `uploading-${crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`}`,
2797
+ name: file.name || 'image',
2798
+ sessionId: activeSessionId,
2799
+ uploading: true,
2800
+ progress: 0,
2801
+ progressKnown: true,
2802
+ status: '0%',
2803
+ abortUpload: null
2804
+ };
2805
+ selectedImageAttachments.push(pending);
2806
+ renderImageAttachments();
2807
+ try {
2808
+ const upload = uploadImageInChunks(activeSessionId, file, progress => {
2809
+ pending.progress = progress;
2810
+ pending.status = `${progress}%`;
2811
+ renderImageAttachments();
2812
+ });
2813
+ pending.abortUpload = upload.abort;
2814
+ const attachment = await upload.promise;
2815
+ const index = selectedImageAttachments.indexOf(pending);
2816
+ if (index < 0) {
2817
+ await fetchWithTimeout(`/api/sessions/${activeSessionId}/attachments/images/${encodeURIComponent(attachment.id)}`, { method: 'DELETE' });
2818
+ continue;
2819
+ }
2820
+ selectedImageAttachments[index] = { ...attachment, name: file.name || attachment.name, sessionId: activeSessionId };
2821
+ renderImageAttachments();
2822
+ } catch (e) {
2823
+ selectedImageAttachments = selectedImageAttachments.filter(item => item !== pending);
2824
+ renderImageAttachments();
2825
+ if (e.message === 'Image upload cancelled') continue;
2826
+ alert(`Could not add ${file.name}: ${e.message}`);
2827
+ }
2828
+ }
2829
+ }
2830
+
2831
+ function closeComposerMenu() {
2832
+ document.getElementById('composer-menu').classList.remove('active');
2833
+ syncComposerButtonState();
2834
+ updateTerminalControlsHeight();
2835
+ }
2836
+
2837
+ function openTimedSendPanel() {
2838
+ closeComposerMenu();
2839
+ if (isClaudeSession()) {
2840
+ closeClaudePicker();
2841
+ closeClaudeUsagePanel();
2842
+ claudeResumePanelOpen = false;
2843
+ document.getElementById('claude-resume-panel').classList.remove('active');
2844
+ }
2845
+ initTimedDelaySelectors();
2846
+ resetTimedEditor({ keepInput: true });
2847
+ document.getElementById('timed-send-panel').classList.add('active');
2848
+ updateTimedSendPreview();
2849
+ loadTimedInputs();
2850
+ syncComposerButtonState();
2851
+ updateTerminalControlsHeight();
2852
+ }
2853
+
2608
2854
  function markInputEditStart() {
2609
2855
  keepTerminalBottomForNextInput = keepTerminalBottomForNextInput || isTerminalAtBottom();
2610
2856
  }
@@ -2620,6 +2866,11 @@
2620
2866
 
2621
2867
  function performSend() {
2622
2868
  const val = inputEl.value;
2869
+ const readyImageAttachments = selectedImageAttachments.filter(item => !item.uploading);
2870
+ if (selectedImageAttachments.some(item => item.uploading)) {
2871
+ alert('Wait for image uploads to finish before sending.');
2872
+ return;
2873
+ }
2623
2874
  if (val && isClaudeSession()) {
2624
2875
  if (currentSocket && currentSocket.readyState === 1) {
2625
2876
  currentSocket.send(JSON.stringify({ type: 'claude-input', text: val }));
@@ -2628,10 +2879,18 @@
2628
2879
  inputEl.style.height = '38px';
2629
2880
  return;
2630
2881
  }
2631
- if (val && isCodexSession() && codexState.presentation === 'structured') {
2632
- if (currentSocket && currentSocket.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-input', text: val }));
2882
+ if ((val || readyImageAttachments.length) && isCodexSession() && codexState.presentation === 'structured') {
2883
+ if (currentSocket && currentSocket.readyState === 1) {
2884
+ currentSocket.send(JSON.stringify({
2885
+ type: 'codex-input',
2886
+ text: val,
2887
+ attachmentIds: readyImageAttachments.map(item => item.id)
2888
+ }));
2889
+ }
2633
2890
  inputEl.value = '';
2634
2891
  inputEl.style.height = '38px';
2892
+ selectedImageAttachments = [];
2893
+ renderImageAttachments();
2635
2894
  return;
2636
2895
  }
2637
2896
  if (val) {
@@ -2710,9 +2969,9 @@
2710
2969
 
2711
2970
  function closeTimedSendPanel() {
2712
2971
  document.getElementById('timed-send-panel').classList.remove('active');
2713
- document.getElementById('timer-btn').classList.remove('active');
2714
2972
  editingTimedInputId = null;
2715
2973
  renderTimedTags();
2974
+ syncComposerButtonState();
2716
2975
  updateTerminalControlsHeight();
2717
2976
  }
2718
2977
 
@@ -2761,6 +3020,7 @@
2761
3020
  function editTimedInput(id) {
2762
3021
  const item = timedInputs.find(value => value.id === id);
2763
3022
  if (!item) return;
3023
+ closeComposerMenu();
2764
3024
  initTimedDelaySelectors();
2765
3025
  editingTimedInputId = id;
2766
3026
  inputEl.value = item.text || '';
@@ -2771,9 +3031,9 @@
2771
3031
  document.getElementById('timed-cancel-edit-btn').style.display = '';
2772
3032
  document.getElementById('timed-delete-btn').style.display = '';
2773
3033
  document.getElementById('timed-send-panel').classList.add('active');
2774
- document.getElementById('timer-btn').classList.add('active');
2775
3034
  updateTimedSendPreview();
2776
3035
  renderTimedTags();
3036
+ syncComposerButtonState();
2777
3037
  updateTerminalControlsHeight();
2778
3038
  }
2779
3039
 
@@ -2819,24 +3079,23 @@
2819
3079
 
2820
3080
  document.getElementById('send-btn').addEventListener('click', performSend);
2821
3081
  document.getElementById('timer-btn').addEventListener('click', () => {
2822
- const panel = document.getElementById('timed-send-panel');
2823
- const isOpen = panel.classList.toggle('active');
2824
- document.getElementById('timer-btn').classList.toggle('active', isOpen);
2825
- if (isOpen) {
2826
- if (isClaudeSession()) {
2827
- closeClaudePicker();
2828
- closeClaudeUsagePanel();
2829
- claudeResumePanelOpen = false;
2830
- document.getElementById('claude-resume-panel').classList.remove('active');
2831
- }
2832
- initTimedDelaySelectors();
2833
- resetTimedEditor({ keepInput: true });
2834
- updateTimedSendPreview();
2835
- loadTimedInputs();
2836
- }
2837
- else closeTimedSendPanel();
3082
+ const menu = document.getElementById('composer-menu');
3083
+ const willOpen = !menu.classList.contains('active');
3084
+ document.getElementById('timed-send-panel').classList.remove('active');
3085
+ menu.classList.toggle('active', willOpen);
3086
+ syncComposerButtonState();
2838
3087
  updateTerminalControlsHeight();
2839
3088
  });
3089
+ document.getElementById('attach-image-btn').addEventListener('click', () => {
3090
+ closeComposerMenu();
3091
+ imageFileInput.click();
3092
+ });
3093
+ document.getElementById('schedule-send-btn').addEventListener('click', openTimedSendPanel);
3094
+ imageFileInput.addEventListener('change', () => {
3095
+ const files = imageFileInput.files;
3096
+ if (files?.length) void uploadImageFiles(files);
3097
+ imageFileInput.value = '';
3098
+ });
2840
3099
  inputEl.addEventListener('keydown', (e) => {
2841
3100
  if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); performSend(); }
2842
3101
  else if (e.key === 'Enter' || e.key === 'Backspace' || e.key === 'Delete') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glad-web",
3
- "version": "1.0.23",
3
+ "version": "1.0.25",
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": {
@@ -52,9 +52,9 @@
52
52
  "conf": "^10.2.0",
53
53
  "express": "^5.2.1",
54
54
  "node-pty": "npm:@lydell/node-pty@^1.1.0",
55
- "uuid": "^9.0.0",
55
+ "uuid": "^11.1.1",
56
56
  "ws": "^8.19.0",
57
- "xterm-headless": "^5.3.0"
57
+ "@xterm/headless": "^6.0.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "caxa": "^3.0.1"