aline-ai 0.6.3__py3-none-any.whl → 0.6.5__py3-none-any.whl

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.
@@ -15,7 +15,9 @@ import asyncio
15
15
  import os
16
16
  import re
17
17
  import shlex
18
+ import time
18
19
  import traceback
20
+ from datetime import datetime, timedelta, timezone
19
21
  from pathlib import Path
20
22
  from typing import Callable, Union
21
23
 
@@ -385,13 +387,175 @@ class TerminalPanel(Container, can_focus=True):
385
387
  # Native terminal: check provider
386
388
  return w.provider == "claude"
387
389
 
388
- # tmux: check window name and tags
389
390
  window_name = (w.window_name or "").strip().lower()
391
+ if re.fullmatch(r"codex(?:-\d+)?", window_name or ""):
392
+ return False
393
+
394
+ # tmux: prefer explicit provider/session_type tags
395
+ if (w.provider or w.session_type):
396
+ return (w.provider == "claude") or (w.session_type == "claude")
397
+
398
+ # tmux: fallback to window name heuristic
390
399
  if re.fullmatch(r"cc(?:-\d+)?", window_name or ""):
391
400
  return True
401
+ return False
402
+
403
+ @staticmethod
404
+ def _is_codex_window(w: WindowData) -> bool:
405
+ """Check if a window is a Codex terminal."""
406
+ if isinstance(w, TerminalInfo):
407
+ return w.provider == "codex"
408
+
409
+ # tmux: prefer explicit provider/session_type tags
410
+ if (w.provider or w.session_type):
411
+ return (w.provider == "codex") or (w.session_type == "codex")
412
+
413
+ window_name = (w.window_name or "").strip().lower()
414
+ if re.fullmatch(r"codex(?:-\d+)?", window_name or ""):
415
+ return True
416
+
417
+ return False
418
+
419
+ @classmethod
420
+ def _supports_context(cls, w: WindowData) -> bool:
421
+ return cls._is_claude_window(w) or cls._is_codex_window(w)
422
+
423
+ @staticmethod
424
+ def _is_internal_tmux_window(w: tmux_manager.InnerWindow) -> bool:
425
+ """Hide internal tmux windows (e.g., the reserved 'home' window)."""
426
+ if not w.no_track:
427
+ return False
428
+ if (w.window_name or "").strip().lower() != "home":
429
+ return False
430
+ return not any(
431
+ (
432
+ (w.terminal_id or "").strip(),
433
+ (w.provider or "").strip(),
434
+ (w.session_type or "").strip(),
435
+ (w.session_id or "").strip(),
436
+ (w.context_id or "").strip(),
437
+ (w.transcript_path or "").strip(),
438
+ (w.attention or "").strip(),
439
+ )
440
+ )
441
+
442
+ def _maybe_link_codex_session_for_terminal(
443
+ self, *, terminal_id: str, created_at: float | None
444
+ ) -> None:
445
+ """Best-effort: bind a Codex session file to a dashboard terminal (no watcher required)."""
446
+ terminal_id = (terminal_id or "").strip()
447
+ if not terminal_id:
448
+ return
392
449
 
393
- is_claude_tagged = (w.provider == "claude") or (w.session_type == "claude")
394
- return bool(is_claude_tagged and (w.terminal_id or w.context_id))
450
+ now = time.time()
451
+ last = self._codex_link_last_attempt.get(terminal_id, 0.0)
452
+ if now - last < 2.0:
453
+ return
454
+ self._codex_link_last_attempt[terminal_id] = now
455
+
456
+ try:
457
+ from ...codex_terminal_linker import read_codex_session_meta
458
+ from ...db import get_database
459
+ from ...codex_home import codex_sessions_dir_for_terminal
460
+ except Exception:
461
+ return
462
+
463
+ try:
464
+ db = get_database(read_only=False)
465
+ agent = db.get_agent_by_id(terminal_id)
466
+ if not agent or agent.provider != "codex" or agent.status != "active":
467
+ return
468
+ if agent.session_id:
469
+ return
470
+ cwd = (agent.cwd or "").strip()
471
+ if not cwd:
472
+ return
473
+ except Exception:
474
+ return
475
+
476
+ candidates: list[Path] = []
477
+ sessions_root = codex_sessions_dir_for_terminal(terminal_id)
478
+ if sessions_root.exists():
479
+ # Deterministic: isolated per-terminal CODEX_HOME.
480
+ try:
481
+ candidates = list(sessions_root.rglob("rollout-*.jsonl"))
482
+ except Exception:
483
+ candidates = []
484
+ else:
485
+ # Fallback for legacy terminals not launched with isolated CODEX_HOME.
486
+ try:
487
+ from ...codex_detector import find_codex_sessions_for_project
488
+
489
+ candidates = find_codex_sessions_for_project(Path(cwd), days_back=3)
490
+ except Exception:
491
+ candidates = []
492
+ if not candidates:
493
+ return
494
+
495
+ created_dt: datetime | None = None
496
+ if created_at is not None:
497
+ try:
498
+ created_dt = datetime.fromtimestamp(float(created_at), tz=timezone.utc)
499
+ except Exception:
500
+ created_dt = None
501
+
502
+ best: Path | None = None
503
+ best_score: float | None = None
504
+ candidates.sort(key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True)
505
+ for session_file in candidates[:200]:
506
+ meta = read_codex_session_meta(session_file)
507
+ if meta is None or (meta.cwd or "").strip() != cwd:
508
+ continue
509
+
510
+ started_dt: datetime | None = meta.started_at
511
+ if started_dt is None:
512
+ try:
513
+ started_dt = datetime.fromtimestamp(
514
+ session_file.stat().st_mtime, tz=timezone.utc
515
+ )
516
+ except Exception:
517
+ started_dt = None
518
+ if started_dt is None:
519
+ continue
520
+
521
+ if created_dt is not None:
522
+ delta = abs((started_dt - created_dt).total_seconds())
523
+ else:
524
+ try:
525
+ delta = abs(time.time() - session_file.stat().st_mtime)
526
+ except Exception:
527
+ continue
528
+
529
+ penalty = 0.0
530
+ origin = (meta.originator or "").lower()
531
+ if "vscode" in origin:
532
+ penalty += 3600.0
533
+ score = float(delta) + penalty
534
+
535
+ if best_score is None or score < best_score:
536
+ best_score = score
537
+ best = session_file
538
+
539
+ if not best:
540
+ return
541
+
542
+ # Avoid binding wildly unrelated sessions.
543
+ if best_score is not None and best_score > 6 * 60 * 60:
544
+ return
545
+
546
+ try:
547
+ db.update_agent(
548
+ terminal_id,
549
+ provider="codex",
550
+ session_type="codex",
551
+ session_id=best.stem,
552
+ transcript_path=str(best),
553
+ cwd=cwd,
554
+ project_dir=cwd,
555
+ source="dashboard:auto-link",
556
+ )
557
+ except Exception:
558
+ return
395
559
 
396
560
  def __init__(self, use_native_terminal: bool | None = None) -> None:
397
561
  """Initialize the terminal panel.
@@ -420,6 +584,9 @@ class TerminalPanel(Container, can_focus=True):
420
584
  self._native_backend: TerminalBackend | None = None
421
585
  self._native_backend_checked = False
422
586
 
587
+ # Best-effort Codex session binding without requiring the watcher process.
588
+ self._codex_link_last_attempt: dict[str, float] = {}
589
+
423
590
  def compose(self) -> ComposeResult:
424
591
  logger.debug("TerminalPanel.compose() started")
425
592
  try:
@@ -501,10 +668,63 @@ class TerminalPanel(Container, can_focus=True):
501
668
 
502
669
  async def refresh_data(self) -> None:
503
670
  async with self._refresh_lock:
671
+ t_start = time.time()
672
+ # Check and close stale terminals if enabled
673
+ await self._close_stale_terminals_if_enabled()
674
+ logger.debug(f"[PERF] _close_stale_terminals_if_enabled: {time.time() - t_start:.3f}s")
675
+
676
+ t_refresh = time.time()
504
677
  if self._is_native_mode():
505
678
  await self._refresh_native_data()
506
679
  else:
507
680
  await self._refresh_tmux_data()
681
+ logger.debug(f"[PERF] total refresh: {time.time() - t_start:.3f}s")
682
+
683
+ async def _close_stale_terminals_if_enabled(self) -> None:
684
+ """Close terminals that haven't been updated for the configured hours."""
685
+ try:
686
+ from ...config import ReAlignConfig
687
+
688
+ config = ReAlignConfig.load()
689
+ if not config.auto_close_stale_terminals:
690
+ return
691
+
692
+ stale_hours = config.stale_terminal_hours or 24
693
+ cutoff_time = datetime.now() - timedelta(hours=stale_hours)
694
+
695
+ # Get stale agents from database
696
+ from ...db import get_database
697
+
698
+ db = get_database(read_only=True)
699
+ all_agents = db.list_agents(status="active", limit=1000)
700
+
701
+ stale_agent_ids = set()
702
+ for agent in all_agents:
703
+ if agent.updated_at and agent.updated_at < cutoff_time:
704
+ stale_agent_ids.add(agent.id)
705
+
706
+ if not stale_agent_ids:
707
+ return
708
+
709
+ # Get current windows
710
+ if self._is_native_mode():
711
+ backend = await self._ensure_native_backend()
712
+ if not backend:
713
+ return
714
+ windows = await backend.list_tabs()
715
+ for w in windows:
716
+ if w.terminal_id in stale_agent_ids:
717
+ logger.info(f"Auto-closing stale terminal: {w.terminal_id}")
718
+ await backend.close_tab(w.session_id)
719
+ else:
720
+ windows = tmux_manager.list_inner_windows()
721
+ for w in windows:
722
+ if w.terminal_id in stale_agent_ids:
723
+ logger.info(f"Auto-closing stale terminal: {w.terminal_id}")
724
+ tmux_manager.kill_inner_window(w.window_id)
725
+
726
+ except Exception as e:
727
+ logger.debug(f"Error checking stale terminals: {e}")
508
728
 
509
729
  async def _refresh_native_data(self) -> None:
510
730
  """Refresh data using native terminal backend."""
@@ -525,24 +745,36 @@ class TerminalPanel(Container, can_focus=True):
525
745
  logger.error(f"Failed to list native terminals: {e}")
526
746
  return
527
747
 
748
+ # Yield to event loop to keep UI responsive
749
+ await asyncio.sleep(0)
750
+
751
+ # NOTE: _maybe_link_codex_session_for_terminal is intentionally skipped here
752
+ # because it performs expensive file system scans (find_codex_sessions_for_project)
753
+ # that can take minutes with many session files. Codex session linking is handled
754
+ # by the watcher process instead.
755
+
528
756
  active_window_id = next(
529
757
  (w.session_id for w in windows if w.active), None
530
758
  )
531
759
  if self._expanded_window_id and self._expanded_window_id != active_window_id:
532
760
  self._expanded_window_id = None
533
761
 
534
- # Get Claude session IDs for title lookup
762
+ # Titles (best-effort; native terminals only expose Claude session ids today)
535
763
  claude_ids = [
536
- w.claude_session_id for w in windows
764
+ w.claude_session_id
765
+ for w in windows
537
766
  if self._is_claude_window(w) and w.claude_session_id
538
767
  ]
539
768
  titles = self._fetch_claude_session_titles(claude_ids)
540
769
 
770
+ # Yield to event loop after DB query
771
+ await asyncio.sleep(0)
772
+
541
773
  # Get context info
542
774
  context_info_by_context_id: dict[str, tuple[list[str], int, int]] = {}
543
775
  all_context_session_ids: set[str] = set()
544
776
  for w in windows:
545
- if not self._is_claude_window(w) or not w.context_id:
777
+ if not self._supports_context(w) or not w.context_id:
546
778
  continue
547
779
  session_ids, session_count, event_count = self._get_loaded_context_info(
548
780
  w.context_id
@@ -566,6 +798,7 @@ class TerminalPanel(Container, can_focus=True):
566
798
 
567
799
  async def _refresh_tmux_data(self) -> None:
568
800
  """Refresh data using tmux backend."""
801
+ t0 = time.time()
569
802
  try:
570
803
  supported = self.supported()
571
804
  except Exception:
@@ -578,20 +811,34 @@ class TerminalPanel(Container, can_focus=True):
578
811
  windows = tmux_manager.list_inner_windows()
579
812
  except Exception:
580
813
  return
814
+ windows = [w for w in windows if not self._is_internal_tmux_window(w)]
815
+ logger.debug(f"[PERF] list_inner_windows: {time.time() - t0:.3f}s")
816
+
817
+ # Yield to event loop to keep UI responsive
818
+ await asyncio.sleep(0)
819
+
820
+ # NOTE: _maybe_link_codex_session_for_terminal is intentionally skipped here
821
+ # because it performs expensive file system scans (find_codex_sessions_for_project)
822
+ # that can take minutes with many session files. Codex session linking is handled
823
+ # by the watcher process instead.
581
824
 
582
825
  active_window_id = next((w.window_id for w in windows if w.active), None)
583
826
  if self._expanded_window_id and self._expanded_window_id != active_window_id:
584
827
  self._expanded_window_id = None
585
828
 
586
- claude_ids = [
587
- w.session_id for w in windows if self._is_claude_window(w) and w.session_id
588
- ]
589
- titles = self._fetch_claude_session_titles(claude_ids)
829
+ t1 = time.time()
830
+ session_ids = [w.session_id for w in windows if self._supports_context(w) and w.session_id]
831
+ titles = self._fetch_claude_session_titles(session_ids)
832
+ logger.debug(f"[PERF] fetch_claude_session_titles: {time.time() - t1:.3f}s")
833
+
834
+ # Yield to event loop after DB query
835
+ await asyncio.sleep(0)
590
836
 
837
+ t2 = time.time()
591
838
  context_info_by_context_id: dict[str, tuple[list[str], int, int]] = {}
592
839
  all_context_session_ids: set[str] = set()
593
840
  for w in windows:
594
- if not self._is_claude_window(w) or not w.context_id:
841
+ if not self._supports_context(w) or not w.context_id:
595
842
  continue
596
843
  session_ids, session_count, event_count = self._get_loaded_context_info(
597
844
  w.context_id
@@ -604,16 +851,27 @@ class TerminalPanel(Container, can_focus=True):
604
851
  event_count,
605
852
  )
606
853
  all_context_session_ids.update(session_ids)
854
+ # Yield periodically during context info gathering
855
+ await asyncio.sleep(0)
856
+ logger.debug(f"[PERF] get_loaded_context_info loop: {time.time() - t2:.3f}s")
607
857
 
858
+ t3 = time.time()
608
859
  if all_context_session_ids:
609
860
  titles.update(self._fetch_claude_session_titles(sorted(all_context_session_ids)))
861
+ logger.debug(f"[PERF] fetch context session titles: {time.time() - t3:.3f}s")
610
862
 
863
+ t4 = time.time()
611
864
  try:
612
865
  await self._render_terminals_tmux(windows, titles, context_info_by_context_id)
613
866
  except Exception:
614
867
  return
868
+ logger.debug(f"[PERF] render_terminals_tmux: {time.time() - t4:.3f}s")
615
869
 
616
870
  def _fetch_claude_session_titles(self, session_ids: list[str]) -> dict[str, str]:
871
+ # Back-compat hook for tests and older call sites.
872
+ return self._fetch_session_titles(session_ids)
873
+
874
+ def _fetch_session_titles(self, session_ids: list[str]) -> dict[str, str]:
617
875
  if not session_ids:
618
876
  return {}
619
877
  try:
@@ -701,7 +959,7 @@ class TerminalPanel(Container, can_focus=True):
701
959
  loaded_ids: list[str] = []
702
960
  raw_sessions = 0
703
961
  raw_events = 0
704
- if self._is_claude_window(w) and w.context_id:
962
+ if self._supports_context(w) and w.context_id:
705
963
  loaded_ids, raw_sessions, raw_events = context_info_by_context_id.get(
706
964
  w.context_id, ([], 0, 0)
707
965
  )
@@ -716,9 +974,7 @@ class TerminalPanel(Container, can_focus=True):
716
974
  )
717
975
  )
718
976
 
719
- can_toggle_ctx = bool(
720
- self._is_claude_window(w) and w.context_id and (raw_sessions or raw_events)
721
- )
977
+ can_toggle_ctx = bool(self._supports_context(w) and w.context_id and (raw_sessions or raw_events))
722
978
  expanded = bool(w.active and w.session_id == self._expanded_window_id)
723
979
  if w.active and can_toggle_ctx:
724
980
  await row.mount(
@@ -741,7 +997,7 @@ class TerminalPanel(Container, can_focus=True):
741
997
  )
742
998
  )
743
999
 
744
- if w.active and self._is_claude_window(w) and w.context_id and expanded:
1000
+ if w.active and self._supports_context(w) and w.context_id and expanded:
745
1001
  ctx = VerticalScroll(id=f"ctx-{safe}", classes="context-sessions")
746
1002
  await container.mount(ctx)
747
1003
  if loaded_ids:
@@ -790,7 +1046,7 @@ class TerminalPanel(Container, can_focus=True):
790
1046
  loaded_ids: list[str] = []
791
1047
  raw_sessions = 0
792
1048
  raw_events = 0
793
- if self._is_claude_window(w) and w.context_id:
1049
+ if self._supports_context(w) and w.context_id:
794
1050
  loaded_ids, raw_sessions, raw_events = context_info_by_context_id.get(
795
1051
  w.context_id, ([], 0, 0)
796
1052
  )
@@ -803,9 +1059,7 @@ class TerminalPanel(Container, can_focus=True):
803
1059
  classes=switch_classes,
804
1060
  )
805
1061
  )
806
- can_toggle_ctx = bool(
807
- self._is_claude_window(w) and w.context_id and (raw_sessions or raw_events)
808
- )
1062
+ can_toggle_ctx = bool(self._supports_context(w) and w.context_id and (raw_sessions or raw_events))
809
1063
  expanded = bool(w.active and w.window_id == self._expanded_window_id)
810
1064
  if w.active and can_toggle_ctx:
811
1065
  await row.mount(
@@ -827,7 +1081,7 @@ class TerminalPanel(Container, can_focus=True):
827
1081
  )
828
1082
  )
829
1083
 
830
- if w.active and self._is_claude_window(w) and w.context_id and expanded:
1084
+ if w.active and self._supports_context(w) and w.context_id and expanded:
831
1085
  ctx = VerticalScroll(id=f"ctx-{safe}", classes="context-sessions")
832
1086
  await container.mount(ctx)
833
1087
  if loaded_ids:
@@ -868,9 +1122,26 @@ class TerminalPanel(Container, can_focus=True):
868
1122
  raw_events: int = 0,
869
1123
  ) -> str | Text:
870
1124
  """Generate label for native terminal window."""
871
- if not self._is_claude_window(w):
1125
+ if not self._supports_context(w):
872
1126
  return Text(w.name, no_wrap=True, overflow="ellipsis")
873
1127
 
1128
+ if self._is_codex_window(w):
1129
+ details = Text(no_wrap=True, overflow="ellipsis")
1130
+ details.append("Codex")
1131
+ details.append("\n")
1132
+ detail_line = "[Codex]"
1133
+ if w.active:
1134
+ loaded_count = raw_sessions + raw_events
1135
+ detail_line = f"{detail_line} | loaded context: {loaded_count}"
1136
+ else:
1137
+ detail_line = (
1138
+ f"{detail_line} · {self._format_context_summary(raw_sessions, raw_events)}"
1139
+ )
1140
+ if w.metadata.get("no_track") == "1":
1141
+ detail_line = f"{detail_line} [NT]"
1142
+ details.append(detail_line, style="dim not bold")
1143
+ return details
1144
+
874
1145
  title = titles.get(w.claude_session_id or "", "").strip() if w.claude_session_id else ""
875
1146
  header = title or ("Claude" if w.claude_session_id else "New Claude")
876
1147
 
@@ -902,9 +1173,32 @@ class TerminalPanel(Container, can_focus=True):
902
1173
  raw_events: int = 0,
903
1174
  ) -> str | Text:
904
1175
  """Generate label for tmux window."""
905
- if not self._is_claude_window(w):
1176
+ if not self._supports_context(w):
906
1177
  return Text(w.window_name, no_wrap=True, overflow="ellipsis")
907
1178
 
1179
+ if self._is_codex_window(w):
1180
+ title = titles.get(w.session_id or "", "").strip() if w.session_id else ""
1181
+ header = title or ("Codex" if w.session_id else "New Codex")
1182
+
1183
+ details = Text(no_wrap=True, overflow="ellipsis")
1184
+ details.append(header)
1185
+ details.append("\n")
1186
+
1187
+ detail_line = "[Codex]"
1188
+ if w.session_id:
1189
+ detail_line = f"{detail_line} #{self._short_id(w.session_id)}"
1190
+ if w.active:
1191
+ loaded_count = raw_sessions + raw_events
1192
+ detail_line = f"{detail_line} | loaded context: {loaded_count}"
1193
+ else:
1194
+ detail_line = (
1195
+ f"{detail_line} · {self._format_context_summary(raw_sessions, raw_events)}"
1196
+ )
1197
+ if w.no_track:
1198
+ detail_line = f"{detail_line} [NT]"
1199
+ details.append(detail_line, style="dim not bold")
1200
+ return details
1201
+
908
1202
  title = titles.get(w.session_id or "", "").strip() if w.session_id else ""
909
1203
  header = title or ("Claude" if w.session_id else "New Claude")
910
1204
 
@@ -956,13 +1250,23 @@ class TerminalPanel(Container, can_focus=True):
956
1250
  return
957
1251
 
958
1252
  agent_type, workspace, skip_permissions, no_track = result
959
- self.run_worker(
960
- self._create_agent(
961
- agent_type, workspace, skip_permissions=skip_permissions, no_track=no_track
962
- ),
963
- group="terminal-panel-create",
964
- exclusive=True,
965
- )
1253
+
1254
+ # Capture self reference for use in the deferred callback
1255
+ panel = self
1256
+
1257
+ # Use app.call_later to defer worker creation until after the modal is dismissed.
1258
+ # This ensures the modal screen is fully closed before the worker starts,
1259
+ # preventing UI update conflicts between modal closing and terminal panel refresh.
1260
+ def start_worker() -> None:
1261
+ panel.run_worker(
1262
+ panel._create_agent(
1263
+ agent_type, workspace, skip_permissions=skip_permissions, no_track=no_track
1264
+ ),
1265
+ group="terminal-panel-create",
1266
+ exclusive=True,
1267
+ )
1268
+
1269
+ self.app.call_later(start_worker)
966
1270
 
967
1271
  async def _create_agent(
968
1272
  self, agent_type: str, workspace: str, *, skip_permissions: bool = False, no_track: bool = False
@@ -971,12 +1275,19 @@ class TerminalPanel(Container, can_focus=True):
971
1275
  if agent_type == "claude":
972
1276
  await self._create_claude_terminal(workspace, skip_permissions=skip_permissions, no_track=no_track)
973
1277
  elif agent_type == "codex":
974
- await self._create_codex_terminal(workspace)
1278
+ await self._create_codex_terminal(workspace, no_track=no_track)
975
1279
  elif agent_type == "opencode":
976
1280
  await self._create_opencode_terminal(workspace)
977
1281
  elif agent_type == "zsh":
978
1282
  await self._create_zsh_terminal(workspace)
979
- await self.refresh_data()
1283
+ # Schedule refresh in a separate worker to avoid blocking UI.
1284
+ # The refresh involves slow synchronous operations (DB queries, file scans)
1285
+ # that would otherwise freeze the dashboard.
1286
+ self.run_worker(
1287
+ self.refresh_data(),
1288
+ group="terminal-panel-refresh",
1289
+ exclusive=True,
1290
+ )
980
1291
 
981
1292
  async def _create_claude_terminal(
982
1293
  self, workspace: str, *, skip_permissions: bool = False, no_track: bool = False
@@ -1064,6 +1375,7 @@ class TerminalPanel(Container, can_focus=True):
1064
1375
  terminal_id=terminal_id,
1065
1376
  provider="claude",
1066
1377
  context_id=context_id,
1378
+ no_track=no_track,
1067
1379
  )
1068
1380
  if not created:
1069
1381
  self.app.notify("Failed to open Claude terminal", title="Terminal", severity="error")
@@ -1119,16 +1431,54 @@ class TerminalPanel(Container, can_focus=True):
1119
1431
  except Exception:
1120
1432
  pass
1121
1433
 
1122
- async def _create_codex_terminal(self, workspace: str) -> None:
1434
+ async def _create_codex_terminal(self, workspace: str, *, no_track: bool = False) -> None:
1123
1435
  """Create a new Codex terminal."""
1436
+ terminal_id = tmux_manager.new_terminal_id()
1437
+ context_id = tmux_manager.new_context_id("cx")
1438
+
1439
+ # Use per-terminal CODEX_HOME so sessions/config are isolated and binding is deterministic.
1440
+ try:
1441
+ from ...codex_home import prepare_codex_home
1442
+
1443
+ codex_home = prepare_codex_home(terminal_id)
1444
+ except Exception:
1445
+ codex_home = None
1446
+
1447
+ env = {
1448
+ tmux_manager.ENV_TERMINAL_ID: terminal_id,
1449
+ tmux_manager.ENV_TERMINAL_PROVIDER: "codex",
1450
+ tmux_manager.ENV_CONTEXT_ID: context_id,
1451
+ }
1452
+ if codex_home is not None:
1453
+ env["CODEX_HOME"] = str(codex_home)
1454
+ if no_track:
1455
+ env["ALINE_NO_TRACK"] = "1"
1456
+
1457
+ # Persist agent early so the watcher can bind the Codex session file back to this terminal.
1458
+ try:
1459
+ from ...db import get_database
1460
+
1461
+ db = get_database(read_only=False)
1462
+ db.get_or_create_agent(
1463
+ terminal_id,
1464
+ provider="codex",
1465
+ session_type="codex",
1466
+ context_id=context_id,
1467
+ cwd=workspace,
1468
+ project_dir=workspace,
1469
+ source="dashboard",
1470
+ )
1471
+ except Exception:
1472
+ pass
1473
+
1124
1474
  if self._is_native_mode():
1125
1475
  backend = await self._ensure_native_backend()
1126
1476
  if backend:
1127
- terminal_id = tmux_manager.new_terminal_id()
1128
1477
  session_id = await backend.create_tab(
1129
1478
  command="codex",
1130
1479
  terminal_id=terminal_id,
1131
1480
  name="Codex",
1481
+ env=env,
1132
1482
  cwd=workspace,
1133
1483
  )
1134
1484
  if not session_id:
@@ -1141,7 +1491,14 @@ class TerminalPanel(Container, can_focus=True):
1141
1491
  command = self._command_in_directory(
1142
1492
  tmux_manager.zsh_run_and_keep_open("codex"), workspace
1143
1493
  )
1144
- created = tmux_manager.create_inner_window("codex", command)
1494
+ created = tmux_manager.create_inner_window(
1495
+ "codex",
1496
+ tmux_manager.shell_command_with_env(command, env),
1497
+ terminal_id=terminal_id,
1498
+ provider="codex",
1499
+ context_id=context_id,
1500
+ no_track=no_track,
1501
+ )
1145
1502
  if not created:
1146
1503
  self.app.notify("Failed to open Codex terminal", title="Terminal", severity="error")
1147
1504
 
@@ -1173,6 +1530,8 @@ class TerminalPanel(Container, can_focus=True):
1173
1530
 
1174
1531
  async def _create_zsh_terminal(self, workspace: str) -> None:
1175
1532
  """Create a new zsh terminal."""
1533
+ t0 = time.time()
1534
+ logger.info(f"[PERF] _create_zsh_terminal START")
1176
1535
  if self._is_native_mode():
1177
1536
  backend = await self._ensure_native_backend()
1178
1537
  if backend:
@@ -1187,13 +1546,19 @@ class TerminalPanel(Container, can_focus=True):
1187
1546
  self.app.notify(
1188
1547
  "Failed to open zsh terminal", title="Terminal", severity="error"
1189
1548
  )
1549
+ logger.info(f"[PERF] _create_zsh_terminal native END: {time.time() - t0:.3f}s")
1190
1550
  return
1191
1551
 
1192
1552
  # Tmux fallback
1553
+ t1 = time.time()
1193
1554
  command = self._command_in_directory("zsh", workspace)
1555
+ logger.info(f"[PERF] _create_zsh_terminal command ready: {time.time() - t1:.3f}s")
1556
+ t2 = time.time()
1194
1557
  created = tmux_manager.create_inner_window("zsh", command)
1558
+ logger.info(f"[PERF] _create_zsh_terminal create_inner_window: {time.time() - t2:.3f}s")
1195
1559
  if not created:
1196
1560
  self.app.notify("Failed to open zsh terminal", title="Terminal", severity="error")
1561
+ logger.info(f"[PERF] _create_zsh_terminal TOTAL: {time.time() - t0:.3f}s")
1197
1562
 
1198
1563
  async def on_button_pressed(self, event: Button.Pressed) -> None:
1199
1564
  button_id = event.button.id or ""