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.
- {aline_ai-0.6.3.dist-info → aline_ai-0.6.5.dist-info}/METADATA +1 -1
- {aline_ai-0.6.3.dist-info → aline_ai-0.6.5.dist-info}/RECORD +26 -23
- realign/__init__.py +1 -1
- realign/adapters/codex.py +14 -9
- realign/cli.py +42 -235
- realign/codex_detector.py +72 -32
- realign/codex_home.py +85 -0
- realign/codex_terminal_linker.py +172 -0
- realign/commands/__init__.py +2 -2
- realign/commands/add.py +89 -9
- realign/commands/doctor.py +497 -0
- realign/commands/init.py +66 -4
- realign/commands/watcher.py +2 -1
- realign/config.py +10 -1
- realign/dashboard/app.py +2 -149
- realign/dashboard/tmux_manager.py +171 -5
- realign/dashboard/widgets/config_panel.py +91 -11
- realign/dashboard/widgets/sessions_table.py +1 -1
- realign/dashboard/widgets/terminal_panel.py +400 -35
- realign/db/sqlite_db.py +76 -0
- realign/hooks.py +6 -128
- realign/watcher_core.py +50 -0
- {aline_ai-0.6.3.dist-info → aline_ai-0.6.5.dist-info}/WHEEL +0 -0
- {aline_ai-0.6.3.dist-info → aline_ai-0.6.5.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.6.3.dist-info → aline_ai-0.6.5.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.6.3.dist-info → aline_ai-0.6.5.dist-info}/top_level.txt +0 -0
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
|
+
|
realign/commands/__init__.py
CHANGED
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
|
|
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
|
-
|
|
505
|
-
|
|
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
|
-
|
|
573
|
-
("
|
|
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
|
-
|
|
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:
|