aline-ai 0.6.2__py3-none-any.whl → 0.6.4__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.
Files changed (40) hide show
  1. {aline_ai-0.6.2.dist-info → aline_ai-0.6.4.dist-info}/METADATA +1 -1
  2. {aline_ai-0.6.2.dist-info → aline_ai-0.6.4.dist-info}/RECORD +38 -37
  3. realign/__init__.py +1 -1
  4. realign/adapters/__init__.py +0 -3
  5. realign/adapters/codex.py +14 -9
  6. realign/cli.py +42 -236
  7. realign/codex_detector.py +72 -32
  8. realign/codex_home.py +85 -0
  9. realign/codex_terminal_linker.py +172 -0
  10. realign/commands/__init__.py +2 -2
  11. realign/commands/add.py +89 -9
  12. realign/commands/doctor.py +495 -0
  13. realign/commands/export_shares.py +154 -226
  14. realign/commands/init.py +66 -4
  15. realign/commands/watcher.py +30 -80
  16. realign/config.py +9 -46
  17. realign/dashboard/app.py +7 -11
  18. realign/dashboard/screens/event_detail.py +0 -3
  19. realign/dashboard/screens/session_detail.py +0 -1
  20. realign/dashboard/tmux_manager.py +129 -4
  21. realign/dashboard/widgets/config_panel.py +175 -241
  22. realign/dashboard/widgets/events_table.py +71 -128
  23. realign/dashboard/widgets/sessions_table.py +77 -136
  24. realign/dashboard/widgets/terminal_panel.py +349 -27
  25. realign/dashboard/widgets/watcher_panel.py +0 -2
  26. realign/db/sqlite_db.py +77 -2
  27. realign/events/event_summarizer.py +76 -35
  28. realign/events/session_summarizer.py +73 -32
  29. realign/hooks.py +334 -647
  30. realign/llm_client.py +201 -520
  31. realign/triggers/__init__.py +0 -2
  32. realign/triggers/next_turn_trigger.py +4 -5
  33. realign/triggers/registry.py +1 -4
  34. realign/watcher_core.py +53 -35
  35. realign/adapters/antigravity.py +0 -159
  36. realign/triggers/antigravity_trigger.py +0 -140
  37. {aline_ai-0.6.2.dist-info → aline_ai-0.6.4.dist-info}/WHEEL +0 -0
  38. {aline_ai-0.6.2.dist-info → aline_ai-0.6.4.dist-info}/entry_points.txt +0 -0
  39. {aline_ai-0.6.2.dist-info → aline_ai-0.6.4.dist-info}/licenses/LICENSE +0 -0
  40. {aline_ai-0.6.2.dist-info → aline_ai-0.6.4.dist-info}/top_level.txt +0 -0
realign/codex_detector.py CHANGED
@@ -7,6 +7,42 @@ from pathlib import Path
7
7
  from typing import Optional, List
8
8
 
9
9
 
10
+ def _codex_session_roots() -> list[Path]:
11
+ """Return all Codex session root directories to scan (best-effort)."""
12
+ roots: list[Path] = []
13
+
14
+ # Default Codex home: ~/.codex/sessions
15
+ roots.append(Path.home() / ".codex" / "sessions")
16
+
17
+ # Aline-managed per-terminal CODEX_HOME isolation: ~/.aline/codex_homes/*/sessions
18
+ try:
19
+ from .codex_home import aline_codex_homes_dir, codex_sessions_dir_for_home
20
+
21
+ homes = aline_codex_homes_dir()
22
+ if homes.exists():
23
+ for child in homes.iterdir():
24
+ if child.is_dir():
25
+ roots.append(codex_sessions_dir_for_home(child))
26
+ except Exception:
27
+ pass
28
+
29
+ # De-dup + only keep existing directories
30
+ out: list[Path] = []
31
+ seen: set[str] = set()
32
+ for r in roots:
33
+ try:
34
+ rr = r.expanduser()
35
+ except Exception:
36
+ rr = r
37
+ key = str(rr)
38
+ if key in seen:
39
+ continue
40
+ seen.add(key)
41
+ if rr.exists():
42
+ out.append(rr)
43
+ return out
44
+
45
+
10
46
  def find_codex_sessions_for_project(project_path: Path, days_back: int = 7) -> List[Path]:
11
47
  """
12
48
  Find Codex sessions for a given project path.
@@ -22,9 +58,8 @@ def find_codex_sessions_for_project(project_path: Path, days_back: int = 7) -> L
22
58
  Returns:
23
59
  List of session file paths that match the project, sorted by timestamp (newest first)
24
60
  """
25
- codex_sessions_base = Path.home() / ".codex" / "sessions"
26
-
27
- if not codex_sessions_base.exists():
61
+ codex_session_roots = _codex_session_roots()
62
+ if not codex_session_roots:
28
63
  return []
29
64
 
30
65
  # Normalize project path for comparison
@@ -32,36 +67,37 @@ def find_codex_sessions_for_project(project_path: Path, days_back: int = 7) -> L
32
67
 
33
68
  matching_sessions = []
34
69
 
35
- # Search through recent days
36
- for days_ago in range(days_back + 1):
37
- target_date = datetime.now() - timedelta(days=days_ago)
38
- date_path = (
39
- codex_sessions_base
40
- / str(target_date.year)
41
- / f"{target_date.month:02d}"
42
- / f"{target_date.day:02d}"
43
- )
44
-
45
- if not date_path.exists():
46
- continue
47
-
48
- # Check all session files in this date directory
49
- for session_file in date_path.glob("rollout-*.jsonl"):
50
- try:
51
- # Read first line to get session metadata
52
- with open(session_file, "r", encoding="utf-8") as f:
53
- first_line = f.readline()
54
- if first_line:
55
- data = json.loads(first_line)
56
- if data.get("type") == "session_meta":
57
- session_cwd = data.get("payload", {}).get("cwd", "")
58
- # Match the project path
59
- if session_cwd == abs_project_path:
60
- matching_sessions.append(session_file)
61
- except (json.JSONDecodeError, IOError):
62
- # Skip malformed or unreadable files
70
+ # Search through recent days in each root (YYYY/MM/DD layout).
71
+ for root in codex_session_roots:
72
+ for days_ago in range(days_back + 1):
73
+ target_date = datetime.now() - timedelta(days=days_ago)
74
+ date_path = (
75
+ root
76
+ / str(target_date.year)
77
+ / f"{target_date.month:02d}"
78
+ / f"{target_date.day:02d}"
79
+ )
80
+
81
+ if not date_path.exists():
63
82
  continue
64
83
 
84
+ # Check all session files in this date directory
85
+ for session_file in date_path.glob("rollout-*.jsonl"):
86
+ try:
87
+ # Read first line to get session metadata
88
+ with open(session_file, "r", encoding="utf-8") as f:
89
+ first_line = f.readline()
90
+ if first_line:
91
+ data = json.loads(first_line)
92
+ if data.get("type") == "session_meta":
93
+ session_cwd = data.get("payload", {}).get("cwd", "")
94
+ # Match the project path
95
+ if session_cwd == abs_project_path:
96
+ matching_sessions.append(session_file)
97
+ except (json.JSONDecodeError, IOError):
98
+ # Skip malformed or unreadable files
99
+ continue
100
+
65
101
  # Sort by modification time, newest first
66
102
  matching_sessions.sort(key=lambda p: p.stat().st_mtime, reverse=True)
67
103
 
@@ -90,8 +126,12 @@ def get_codex_sessions_dir() -> Optional[Path]:
90
126
  Returns:
91
127
  Path to ~/.codex/sessions if it exists, None otherwise
92
128
  """
129
+ # Preserve old API: return default location if present, else the first discovered root.
93
130
  codex_sessions = Path.home() / ".codex" / "sessions"
94
- return codex_sessions if codex_sessions.exists() else None
131
+ if codex_sessions.exists():
132
+ return codex_sessions
133
+ roots = _codex_session_roots()
134
+ return roots[0] if roots else None
95
135
 
96
136
 
97
137
  def auto_detect_codex_sessions(
realign/codex_home.py ADDED
@@ -0,0 +1,85 @@
1
+ """Codex home/session path helpers.
2
+
3
+ To guarantee terminal↔session binding even when multiple Codex instances run in the same cwd,
4
+ we can isolate Codex storage per dashboard terminal via the `CODEX_HOME` environment variable.
5
+
6
+ We choose deterministic paths under `~/.aline/` so the watcher (a separate process) can
7
+ derive the owning terminal_id purely from the session file path.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+
17
+ ENV_CODEX_HOME = "CODEX_HOME"
18
+
19
+
20
+ def aline_codex_homes_dir() -> Path:
21
+ override = os.environ.get("ALINE_CODEX_HOMES_DIR", "").strip()
22
+ if override:
23
+ return Path(os.path.expanduser(override))
24
+ return Path.home() / ".aline" / "codex_homes"
25
+
26
+
27
+ def codex_home_for_terminal(terminal_id: str) -> Path:
28
+ tid = (terminal_id or "").strip()
29
+ return aline_codex_homes_dir() / tid
30
+
31
+
32
+ def codex_sessions_dir_for_home(codex_home: Path) -> Path:
33
+ return codex_home / "sessions"
34
+
35
+
36
+ def codex_sessions_dir_for_terminal(terminal_id: str) -> Path:
37
+ return codex_sessions_dir_for_home(codex_home_for_terminal(terminal_id))
38
+
39
+
40
+ def terminal_id_from_codex_session_file(session_file: Path) -> Optional[str]:
41
+ """If session_file is under an Aline-managed CODEX_HOME, return terminal_id."""
42
+ try:
43
+ homes = aline_codex_homes_dir().resolve()
44
+ p = session_file.resolve()
45
+ except Exception:
46
+ return None
47
+
48
+ try:
49
+ rel = p.relative_to(homes)
50
+ except ValueError:
51
+ return None
52
+
53
+ parts = rel.parts
54
+ if len(parts) < 3:
55
+ return None
56
+ terminal_id = (parts[0] or "").strip()
57
+ if not terminal_id:
58
+ return None
59
+ if parts[1] != "sessions":
60
+ return None
61
+ return terminal_id
62
+
63
+
64
+ def prepare_codex_home(terminal_id: str) -> Path:
65
+ """Create/prepare an isolated CODEX_HOME for a terminal (best-effort)."""
66
+ home = codex_home_for_terminal(terminal_id)
67
+ sessions = codex_sessions_dir_for_home(home)
68
+ try:
69
+ sessions.mkdir(parents=True, exist_ok=True)
70
+ except Exception:
71
+ pass
72
+
73
+ # Keep Codex skills working under the isolated home by symlinking to the global skills dir.
74
+ try:
75
+ global_skills = Path.home() / ".codex" / "skills"
76
+ if global_skills.exists():
77
+ skills_link = home / "skills"
78
+ if not skills_link.exists():
79
+ skills_link.parent.mkdir(parents=True, exist_ok=True)
80
+ skills_link.symlink_to(global_skills)
81
+ except Exception:
82
+ pass
83
+
84
+ return home
85
+
@@ -0,0 +1,172 @@
1
+ """Best-effort linking between Codex session files and Aline terminals.
2
+
3
+ Claude Code provides explicit hook callbacks with session identifiers. Codex CLI does not,
4
+ so we infer the binding by matching:
5
+ - session_meta.cwd (project/workspace path)
6
+ - session creation time vs. terminal creation time
7
+
8
+ This module is intentionally dependency-light so it can be used by the watcher.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Iterable, Optional, Protocol
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class CodexSessionMeta:
22
+ session_file: Path
23
+ cwd: str
24
+ started_at: Optional[datetime] = None
25
+ originator: Optional[str] = None
26
+ source: Optional[str] = None
27
+
28
+
29
+ def _parse_iso8601(ts: str) -> Optional[datetime]:
30
+ raw = (ts or "").strip()
31
+ if not raw:
32
+ return None
33
+ # Common Codex format: 2025-12-23T09:14:28.152Z
34
+ if raw.endswith("Z"):
35
+ raw = raw[:-1] + "+00:00"
36
+ try:
37
+ dt = datetime.fromisoformat(raw)
38
+ except ValueError:
39
+ return None
40
+ if dt.tzinfo is None:
41
+ dt = dt.replace(tzinfo=timezone.utc)
42
+ return dt
43
+
44
+
45
+ def read_codex_session_meta(session_file: Path) -> Optional[CodexSessionMeta]:
46
+ """Extract Codex session metadata from a session file (best-effort)."""
47
+ try:
48
+ with session_file.open("r", encoding="utf-8") as f:
49
+ for i, line in enumerate(f):
50
+ if i >= 25:
51
+ break
52
+ raw = (line or "").strip()
53
+ if not raw:
54
+ continue
55
+ try:
56
+ data = json.loads(raw)
57
+ except json.JSONDecodeError:
58
+ continue
59
+
60
+ # Typical format: {"type":"session_meta","payload":{...}}
61
+ if data.get("type") == "session_meta":
62
+ payload = data.get("payload") or {}
63
+ cwd = str(payload.get("cwd") or "").strip()
64
+ if not cwd:
65
+ return None
66
+ started_at = _parse_iso8601(str(payload.get("timestamp") or ""))
67
+ originator = str(payload.get("originator") or "").strip() or None
68
+ source = str(payload.get("source") or "").strip() or None
69
+ return CodexSessionMeta(
70
+ session_file=session_file,
71
+ cwd=cwd,
72
+ started_at=started_at,
73
+ originator=originator,
74
+ source=source,
75
+ )
76
+
77
+ # Newer Codex header: first line may have {id, timestamp, git} without "type"
78
+ if i == 0 and "timestamp" in data and "type" not in data:
79
+ started_at = _parse_iso8601(str(data.get("timestamp") or ""))
80
+ git = data.get("git") if isinstance(data.get("git"), dict) else {}
81
+ cwd = ""
82
+ if isinstance(git, dict):
83
+ cwd = str(git.get("cwd") or "").strip()
84
+ if not cwd:
85
+ cwd = str(data.get("cwd") or "").strip()
86
+ if not cwd:
87
+ return None
88
+ return CodexSessionMeta(session_file=session_file, cwd=cwd, started_at=started_at)
89
+ except OSError:
90
+ return None
91
+ except Exception:
92
+ return None
93
+
94
+ return None
95
+
96
+
97
+ class _AgentLike(Protocol):
98
+ id: str
99
+ provider: str
100
+ status: str
101
+ cwd: Optional[str]
102
+ session_id: Optional[str]
103
+ transcript_path: Optional[str]
104
+ created_at: datetime
105
+
106
+
107
+ def select_agent_for_codex_session(
108
+ agents: Iterable[_AgentLike],
109
+ *,
110
+ session: CodexSessionMeta,
111
+ max_time_delta_seconds: int = 6 * 60 * 60,
112
+ ) -> Optional[str]:
113
+ """Pick the best active Codex terminal for a Codex session file (best-effort)."""
114
+ cwd = (session.cwd or "").strip()
115
+ if not cwd:
116
+ return None
117
+
118
+ candidates: list[_AgentLike] = []
119
+ for a in agents:
120
+ try:
121
+ if getattr(a, "status", "") != "active":
122
+ continue
123
+ if getattr(a, "provider", "") != "codex":
124
+ continue
125
+ if (getattr(a, "cwd", None) or "").strip() != cwd:
126
+ continue
127
+ # Avoid clobbering an existing binding to a different session.
128
+ existing_sid = (getattr(a, "session_id", None) or "").strip()
129
+ if existing_sid and existing_sid != session.session_file.stem:
130
+ continue
131
+ existing_path = (getattr(a, "transcript_path", None) or "").strip()
132
+ if existing_path and existing_path != str(session.session_file):
133
+ continue
134
+ candidates.append(a)
135
+ except Exception:
136
+ continue
137
+
138
+ if not candidates:
139
+ return None
140
+ if len(candidates) == 1:
141
+ return candidates[0].id
142
+
143
+ # Pick closest by creation time.
144
+ if session.started_at is not None:
145
+ ref = session.started_at
146
+ else:
147
+ try:
148
+ ref = datetime.fromtimestamp(session.session_file.stat().st_mtime, tz=timezone.utc)
149
+ except OSError:
150
+ ref = datetime.now(tz=timezone.utc)
151
+
152
+ best_id: Optional[str] = None
153
+ best_delta: Optional[float] = None
154
+ for a in candidates:
155
+ try:
156
+ created_at = a.created_at
157
+ if created_at.tzinfo is None:
158
+ created_at = created_at.replace(tzinfo=timezone.utc)
159
+ delta = abs((ref - created_at).total_seconds())
160
+ if best_delta is None or delta < best_delta:
161
+ best_delta = delta
162
+ best_id = a.id
163
+ except Exception:
164
+ continue
165
+
166
+ if best_id is None:
167
+ return None
168
+ if best_delta is not None and best_delta > max_time_delta_seconds:
169
+ # Ambiguous: don't bind if terminals are too far from the session start.
170
+ return None
171
+ return best_id
172
+
@@ -1,5 +1,5 @@
1
1
  """ReAlign commands module."""
2
2
 
3
- from . import init, config
3
+ from . import init, config, doctor
4
4
 
5
- __all__ = ["init", "config"]
5
+ __all__ = ["init", "config", "doctor"]
realign/commands/add.py CHANGED
@@ -2,8 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  import shutil
6
7
  import subprocess
8
+ import sys
7
9
  from pathlib import Path
8
10
 
9
11
  from rich.console import Console
@@ -497,12 +499,60 @@ def _source_aline_tmux_conf(tmux_conf: Path) -> None:
497
499
  continue
498
500
 
499
501
 
500
- def add_tmux_command() -> int:
501
- """Install tmux (via Homebrew) and set up Aline's tmux config."""
502
+ def _find_brew() -> str | None:
502
503
  brew = shutil.which("brew")
504
+ return brew
505
+
506
+
507
+ def _prompt_install_homebrew() -> bool:
508
+ if not sys.stdin.isatty():
509
+ console.print("[red]Homebrew not found.[/red]")
510
+ console.print("[dim]Install from https://brew.sh and retry.[/dim]")
511
+ return False
512
+
513
+ console.print("[yellow]Homebrew not found.[/yellow]")
514
+ console.print("[dim]Aline can install tmux automatically via Homebrew.[/dim]\n")
515
+ try:
516
+ answer = console.input("Install Homebrew now? ([green]y[/green]/[yellow]n[/yellow]): ").strip()
517
+ except (EOFError, KeyboardInterrupt):
518
+ return False
519
+ if answer.lower() not in ("y", "yes"):
520
+ console.print("[dim]Skipped Homebrew install.[/dim]")
521
+ console.print("[dim]Install from https://brew.sh and retry.[/dim]")
522
+ return False
523
+
524
+ console.print("\n[bold]Installing Homebrew (official script)...[/bold]")
525
+ console.print(
526
+ "[dim]Tip: if this fails, follow the manual steps at https://brew.sh[/dim]\n"
527
+ )
528
+ # Official install script (see https://brew.sh).
529
+ proc = subprocess.run(
530
+ [
531
+ "/bin/bash",
532
+ "-c",
533
+ "curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh | /bin/bash",
534
+ ],
535
+ text=True,
536
+ capture_output=False,
537
+ check=False,
538
+ )
539
+ if proc.returncode != 0:
540
+ console.print(f"[red]Failed:[/red] Homebrew install (exit {proc.returncode})")
541
+ return False
542
+ return True
543
+
544
+
545
+ def add_tmux_command(*, install_brew: bool = False) -> int:
546
+ """Install tmux (via Homebrew) and set up Aline's tmux config."""
547
+ brew = _find_brew()
503
548
  if brew is None:
504
- console.print("[red]Homebrew not found.[/red] Install from https://brew.sh and retry.")
505
- return 1
549
+ if sys.platform == "darwin" and install_brew:
550
+ if not _prompt_install_homebrew():
551
+ return 1
552
+ brew = _find_brew()
553
+ if brew is None:
554
+ console.print("[red]Homebrew not found.[/red] Install from https://brew.sh and retry.")
555
+ return 1
506
556
 
507
557
  console.print("[dim]Running: brew install tmux[/dim]")
508
558
  proc = _run([brew, "install", "tmux"])
@@ -554,6 +604,25 @@ def _ensure_symlink(target_link: Path, source_file: Path, force: bool = False) -
554
604
  return True
555
605
 
556
606
 
607
+ def _ensure_copy(target_file: Path, source_file: Path, force: bool = False) -> bool:
608
+ """Copy source_file to target_file.
609
+
610
+ Returns:
611
+ True if target was created/updated, False if skipped.
612
+ """
613
+ if target_file.exists() or target_file.is_symlink():
614
+ if not force:
615
+ return False
616
+ if target_file.is_dir() and not target_file.is_symlink():
617
+ shutil.rmtree(target_file)
618
+ else:
619
+ target_file.unlink()
620
+
621
+ target_file.parent.mkdir(parents=True, exist_ok=True)
622
+ shutil.copy2(source_file, target_file)
623
+ return True
624
+
625
+
557
626
  def add_skills_command(force: bool = False) -> int:
558
627
  """Install Aline skills for Claude Code and Codex.
559
628
 
@@ -567,10 +636,18 @@ def add_skills_command(force: bool = False) -> int:
567
636
  Exit code (0 for success, 1 for failure)
568
637
  """
569
638
  aline_skill_root = Path.home() / ".aline" / "skills"
639
+ codex_home_env = os.environ.get("CODEX_HOME", "").strip()
640
+ codex_home = Path.home() / ".codex"
641
+ if codex_home_env:
642
+ env_path = Path(codex_home_env).expanduser()
643
+ # Avoid installing into per-terminal isolated CODEX_HOME dirs.
644
+ if ".aline/codex_homes" not in str(env_path):
645
+ codex_home = env_path
570
646
  targets = [
571
- ("Claude", Path.home() / ".claude" / "skills"),
572
- ("Codex", Path.home() / ".codex" / "skills"),
573
- ("OpenCode", Path.home() / ".config" / "opencode" / "skill"),
647
+ ("Claude", Path.home() / ".claude" / "skills", "symlink"),
648
+ # Codex skills are safer as real files: some environments/tools ignore symlinks.
649
+ ("Codex", codex_home / "skills", "copy"),
650
+ ("OpenCode", Path.home() / ".config" / "opencode" / "skill", "symlink"),
574
651
  ]
575
652
 
576
653
  installed_skills: list[str] = []
@@ -588,11 +665,14 @@ def add_skills_command(force: bool = False) -> int:
588
665
  continue
589
666
 
590
667
  # 2. Link to targets
591
- for tool_name, tool_root in targets:
668
+ for tool_name, tool_root, mode in targets:
592
669
  dest_path = tool_root / skill_name / "SKILL.md"
593
670
 
594
671
  try:
595
- updated = _ensure_symlink(dest_path, master_path, force)
672
+ if mode == "copy":
673
+ updated = _ensure_copy(dest_path, master_path, force)
674
+ else:
675
+ updated = _ensure_symlink(dest_path, master_path, force)
596
676
  if updated:
597
677
  installed_skills.append(f"{tool_name}/{skill_name}")
598
678
  else: