aline-ai 0.7.3__py3-none-any.whl → 0.7.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.
@@ -0,0 +1,247 @@
1
+ """
2
+ Codex notify hook installer (best-effort).
3
+
4
+ We primarily support the Rust Codex CLI which reads CODEX_HOME/config.toml and
5
+ supports `notify = "command args..."` to run a script when a turn finishes.
6
+
7
+ For legacy Codex config.yaml/config.json formats, we can only set `notify: true`
8
+ to enable built-in notifications; there is no guaranteed script hook in that format.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ from ..logging_config import setup_logger
22
+
23
+ logger = setup_logger("realign.codex_hooks.installer", "codex_hooks_installer.log")
24
+
25
+ ALINE_HOOK_MARKER = "aline-codex-notify-hook"
26
+
27
+
28
+ def get_notify_hook_script_path() -> Path:
29
+ return Path(__file__).parent / "notify_hook.py"
30
+
31
+
32
+ def get_notify_hook_command_parts() -> list[str]:
33
+ script_path = get_notify_hook_script_path()
34
+ return [sys.executable, str(script_path)]
35
+
36
+
37
+ def _toml_escape(s: str) -> str:
38
+ return s.replace("\\", "\\\\").replace('"', '\\"')
39
+
40
+
41
+ def _format_notify_toml(cmd: list[str]) -> str:
42
+ # Codex CLI expects notify as a string (shell command), not an array.
43
+ # NOTE: we intentionally do not attempt complex quoting here; the common case is
44
+ # paths without spaces (e.g. /opt/homebrew/...).
45
+ command_str = " ".join(cmd)
46
+ return f"notify = \"{_toml_escape(command_str)}\" # {ALINE_HOOK_MARKER}\n"
47
+
48
+
49
+ def _update_toml_linewise(path: Path, *, cmd: list[str]) -> bool:
50
+ """
51
+ Update config.toml in a minimal, formatting-preserving way (line-based).
52
+
53
+ Returns True if the file was written/updated.
54
+ """
55
+ desired = _format_notify_toml(cmd)
56
+ existing = ""
57
+ try:
58
+ existing = path.read_text(encoding="utf-8")
59
+ except FileNotFoundError:
60
+ existing = ""
61
+ except Exception:
62
+ return False
63
+
64
+ lines = existing.splitlines(keepends=True) if existing else []
65
+ out: list[str] = []
66
+ replaced = False
67
+
68
+ for line in lines:
69
+ stripped = line.lstrip()
70
+ if stripped.startswith("notify ="):
71
+ if not replaced:
72
+ out.append(desired)
73
+ replaced = True
74
+ else:
75
+ # Drop duplicate notify lines.
76
+ continue
77
+ else:
78
+ out.append(line)
79
+
80
+ if not replaced:
81
+ if out and not out[-1].endswith("\n"):
82
+ out[-1] = out[-1] + "\n"
83
+ if out and out[-1].strip():
84
+ out.append("\n")
85
+ out.append(desired)
86
+
87
+ new_text = "".join(out)
88
+ if new_text == existing:
89
+ return True
90
+
91
+ try:
92
+ path.parent.mkdir(parents=True, exist_ok=True)
93
+ path.write_text(new_text, encoding="utf-8")
94
+ return True
95
+ except Exception:
96
+ return False
97
+
98
+
99
+ def _ensure_legacy_notify_enabled(codex_home: Path) -> list[Path]:
100
+ """
101
+ Best-effort for legacy Codex config formats (YAML/JSON):
102
+ set `notify: true` / `"notify": true` if the file exists.
103
+ """
104
+ updated: list[Path] = []
105
+ yaml_path = codex_home / "config.yaml"
106
+ json_path = codex_home / "config.json"
107
+
108
+ if yaml_path.exists():
109
+ try:
110
+ raw = yaml_path.read_text(encoding="utf-8")
111
+ if "notify:" in raw:
112
+ # Minimal replace: notify: <anything> -> notify: true
113
+ out_lines: list[str] = []
114
+ for line in raw.splitlines():
115
+ if line.strip().startswith("notify:"):
116
+ out_lines.append("notify: true")
117
+ else:
118
+ out_lines.append(line)
119
+ new_raw = "\n".join(out_lines) + ("\n" if raw.endswith("\n") else "")
120
+ else:
121
+ new_raw = raw + ("\n" if raw and not raw.endswith("\n") else "") + "notify: true\n"
122
+ if new_raw != raw:
123
+ yaml_path.write_text(new_raw, encoding="utf-8")
124
+ updated.append(yaml_path)
125
+ except Exception:
126
+ pass
127
+
128
+ if json_path.exists():
129
+ try:
130
+ obj = json.loads(json_path.read_text(encoding="utf-8") or "{}")
131
+ if isinstance(obj, dict):
132
+ if obj.get("notify") is not True:
133
+ obj["notify"] = True
134
+ json_path.write_text(json.dumps(obj, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
135
+ updated.append(json_path)
136
+ except Exception:
137
+ pass
138
+
139
+ return updated
140
+
141
+
142
+ def ensure_notify_hook_installed_for_codex_home(
143
+ codex_home: Path, *, quiet: bool = True
144
+ ) -> bool:
145
+ """
146
+ Ensure the notify hook is installed for a given CODEX_HOME.
147
+
148
+ - Rust CLI: writes/updates CODEX_HOME/config.toml notify command.
149
+ - Legacy: enables notify=true if config.yaml/config.json exist.
150
+ """
151
+ codex_home = Path(codex_home).expanduser()
152
+ cmd = get_notify_hook_command_parts()
153
+
154
+ ok = False
155
+ toml_path = codex_home / "config.toml"
156
+ if _update_toml_linewise(toml_path, cmd=cmd):
157
+ ok = True
158
+ if not quiet:
159
+ print(f"[Aline] Codex notify hook installed: {toml_path}", file=sys.stderr)
160
+ _ensure_legacy_notify_enabled(codex_home)
161
+ return ok
162
+
163
+
164
+ def ensure_global_codex_notify_hook_installed(*, quiet: bool = True) -> bool:
165
+ """Best-effort: install notify hook into default global CODEX_HOME (~/.codex)."""
166
+ return ensure_notify_hook_installed_for_codex_home(Path.home() / ".codex", quiet=quiet)
167
+
168
+
169
+ def ensure_all_aline_codex_homes_notify_hook_installed(*, quiet: bool = True) -> int:
170
+ """
171
+ Best-effort: install notify hook into every Aline-managed CODEX_HOME under ~/.aline/codex_homes.
172
+ Returns number of homes updated.
173
+ """
174
+ try:
175
+ from ..codex_home import aline_codex_homes_dir
176
+
177
+ root = aline_codex_homes_dir()
178
+ except Exception:
179
+ root = Path.home() / ".aline" / "codex_homes"
180
+
181
+ if not root.exists():
182
+ return 0
183
+
184
+ updated = 0
185
+ for child in root.iterdir():
186
+ if not child.is_dir():
187
+ continue
188
+ # Layouts:
189
+ # - <terminal_id>/
190
+ # - agent-<id>/<terminal_id>/
191
+ if child.name.startswith("agent-"):
192
+ try:
193
+ for grandchild in child.iterdir():
194
+ if grandchild.is_dir():
195
+ if ensure_notify_hook_installed_for_codex_home(grandchild, quiet=quiet):
196
+ updated += 1
197
+ except Exception:
198
+ continue
199
+ else:
200
+ if ensure_notify_hook_installed_for_codex_home(child, quiet=quiet):
201
+ updated += 1
202
+ return updated
203
+
204
+
205
+ def codex_cli_supports_notify_hook(*, timeout_seconds: float = 0.5) -> Optional[bool]:
206
+ """
207
+ Best-effort detect whether the installed `codex` binary supports the Rust notify hook.
208
+
209
+ Returns:
210
+ - True: looks like Rust Codex CLI (supports config.toml + notify command)
211
+ - False: looks like legacy Codex (no reliable script notify hook)
212
+ - None: codex binary not found
213
+ """
214
+ if shutil.which("codex") is None:
215
+ return None
216
+
217
+ def run(args: list[str]) -> str:
218
+ try:
219
+ proc = subprocess.run(
220
+ args,
221
+ text=True,
222
+ capture_output=True,
223
+ check=False,
224
+ timeout=float(timeout_seconds),
225
+ )
226
+ return f"{proc.stdout}\n{proc.stderr}".strip().lower()
227
+ except Exception:
228
+ return ""
229
+
230
+ help_out = run(["codex", "--help"])
231
+ ver_out = run(["codex", "--version"])
232
+ out = (help_out + "\n" + ver_out).lower()
233
+
234
+ # Strong positive indicators for Rust CLI.
235
+ if "config.toml" in out and "notify" in out:
236
+ return True
237
+ # Avoid false positives (e.g. "trusted" contains the substring "rust").
238
+ if "codex-rs" in out or re.search(r"\brust\b", out):
239
+ return True
240
+
241
+ # Legacy indicators (YAML/JSON config keys from older docs/CLI).
242
+ if "config.yaml" in out or "approvalmode" in out or "fullautoerrormode" in out:
243
+ return False
244
+
245
+ # Conservative default: if we cannot confirm, treat as unsupported so we don't
246
+ # claim Codex integration works when it won't.
247
+ return False
@@ -131,6 +131,27 @@ def _update_claude_hooks(*, verbose: bool) -> Tuple[list[str], list[str]]:
131
131
  return hooks_updated, hooks_failed
132
132
 
133
133
 
134
+ def _update_codex_notify_hook(*, verbose: bool) -> Tuple[int, int]:
135
+ """
136
+ Ensure Codex notify hook is installed:
137
+ - global ~/.codex
138
+ - all Aline-managed ~/.aline/codex_homes/*
139
+ """
140
+ try:
141
+ from ..codex_hooks.notify_hook_installer import (
142
+ ensure_all_aline_codex_homes_notify_hook_installed,
143
+ ensure_global_codex_notify_hook_installed,
144
+ )
145
+
146
+ ok_global = 1 if ensure_global_codex_notify_hook_installed(quiet=not verbose) else 0
147
+ ok_homes = int(ensure_all_aline_codex_homes_notify_hook_installed(quiet=not verbose))
148
+ return ok_global, ok_homes
149
+ except Exception as e:
150
+ if verbose:
151
+ console.print(f" [yellow]Codex notify hook update failed: {e}[/yellow]")
152
+ return 0, 0
153
+
154
+
134
155
  def _update_skills(*, verbose: bool) -> int:
135
156
  from .add import add_skills_command
136
157
 
@@ -302,6 +323,66 @@ def _check_llm_error_turns(
302
323
  db.close()
303
324
 
304
325
 
326
+ def _check_watcher_backlog(
327
+ config: ReAlignConfig,
328
+ *,
329
+ verbose: bool,
330
+ fix: bool,
331
+ ) -> Tuple[int, int]:
332
+ """
333
+ Check for backlog sessions that changed since the watcher last run.
334
+
335
+ Uses the same 2-phase startup scan logic as the watcher:
336
+ 1) stat previously persisted session paths (fast)
337
+ 2) full scan of watch paths (complete)
338
+ """
339
+ from ..db.sqlite_db import SQLiteDatabase
340
+ from ..watcher_core import DialogueWatcher
341
+
342
+ db_path = Path(config.sqlite_db_path).expanduser()
343
+ if not db_path.exists():
344
+ return 0, 0
345
+
346
+ watcher = DialogueWatcher()
347
+ candidates, _sizes, _mtimes, report = watcher._startup_scan_collect_candidates()
348
+
349
+ if verbose:
350
+ console.print(
351
+ f" [dim]Tracked paths: {report.prev_paths} (missing: {report.prev_missing}, changed: {report.prev_changed})[/dim]"
352
+ )
353
+ console.print(
354
+ f" [dim]Scan paths: {report.scan_paths} (new: {report.scan_new}, changed: {report.scan_changed})[/dim]"
355
+ )
356
+
357
+ if not candidates:
358
+ return 0, 0
359
+
360
+ if not fix:
361
+ return len(candidates), 0
362
+
363
+ db = SQLiteDatabase(str(db_path))
364
+ try:
365
+ enqueued = 0
366
+ for session_file in candidates:
367
+ try:
368
+ db.enqueue_session_process_job(
369
+ session_file_path=session_file,
370
+ session_id=session_file.stem,
371
+ workspace_path=None,
372
+ session_type=watcher._detect_session_type(session_file),
373
+ source_event="doctor_backlog_scan",
374
+ priority=getattr(watcher, "_startup_scan_priority", 5),
375
+ )
376
+ enqueued += 1
377
+ except Exception as e:
378
+ if verbose:
379
+ console.print(f" [yellow]Failed to enqueue {session_file}: {e}[/yellow]")
380
+ continue
381
+ return len(candidates), enqueued
382
+ finally:
383
+ db.close()
384
+
385
+
305
386
  def run_doctor(
306
387
  *,
307
388
  restart_daemons: bool,
@@ -389,6 +470,34 @@ def run_doctor(
389
470
  if hooks_failed:
390
471
  console.print(f" [yellow]![/yellow] Failed hooks: {', '.join(hooks_failed)}")
391
472
 
473
+ # 3b. Update Codex notify hook
474
+ console.print("\n[bold]3b. Updating Codex notify hook...[/bold]")
475
+ try:
476
+ ok_global, ok_homes = _update_codex_notify_hook(verbose=verbose)
477
+ if ok_global:
478
+ console.print(" [green]✓[/green] Updated global Codex config (~/.codex)")
479
+ else:
480
+ console.print(" [dim]Global Codex config not updated (may be missing or unwritable)[/dim]")
481
+ if ok_homes:
482
+ console.print(f" [green]✓[/green] Updated {ok_homes} Aline CODEX_HOME(s)")
483
+ else:
484
+ console.print(" [dim]No Aline CODEX_HOME(s) updated[/dim]")
485
+
486
+ # If Codex exists but is legacy, warn: Aline expects the Rust notify hook.
487
+ try:
488
+ from ..codex_hooks.notify_hook_installer import codex_cli_supports_notify_hook
489
+
490
+ supported = codex_cli_supports_notify_hook()
491
+ if supported is False:
492
+ console.print(" [yellow]![/yellow] Codex CLI does not support notify hook.")
493
+ console.print(
494
+ " [dim]Tip: update to the Rust Codex CLI to enable reliable, event-driven Codex imports.[/dim]"
495
+ )
496
+ except Exception:
497
+ pass
498
+ except Exception as e:
499
+ console.print(f" [yellow]![/yellow] Codex notify hook update failed: {e}")
500
+
392
501
  # 4. Update skills
393
502
  console.print("\n[bold]4. Updating skills...[/bold]")
394
503
  try:
@@ -446,6 +555,22 @@ def run_doctor(
446
555
  except Exception as e:
447
556
  console.print(f" [yellow]![/yellow] Failed to repair associations: {e}")
448
557
 
558
+ # 5c. Check backlog sessions (watcher startup scan semantics)
559
+ console.print("\n[bold]5c. Checking watcher backlog sessions...[/bold]")
560
+ try:
561
+ backlog_count, _ = _check_watcher_backlog(config, verbose=verbose, fix=False)
562
+ if backlog_count == 0:
563
+ console.print(" [green]✓[/green] No backlog sessions detected")
564
+ else:
565
+ console.print(f" [yellow]![/yellow] Found {backlog_count} backlog session(s) to process")
566
+ if auto_fix or typer.confirm("\n Do you want to enqueue these for processing now?", default=True):
567
+ _, enqueued = _check_watcher_backlog(config, verbose=verbose, fix=True)
568
+ console.print(f" [green]✓[/green] Enqueued {enqueued} session_process job(s)")
569
+ else:
570
+ console.print(" [dim]Skipped enqueueing backlog sessions[/dim]")
571
+ except Exception as e:
572
+ console.print(f" [yellow]![/yellow] Failed to check watcher backlog: {e}")
573
+
449
574
  # 6. Restart/ensure daemons
450
575
  if restart_daemons:
451
576
  console.print("\n[bold]6. Checking daemons...[/bold]")
@@ -47,6 +47,7 @@ else:
47
47
  def download_share_data(
48
48
  share_url: str,
49
49
  password: Optional[str] = None,
50
+ cache_buster: Optional[str] = None,
50
51
  ) -> Dict[str, Any]:
51
52
  """
52
53
  Download share data from a share URL.
@@ -62,7 +63,10 @@ def download_share_data(
62
63
  {"success": False, "error": str} on failure
63
64
  """
64
65
  if not HTTPX_AVAILABLE:
65
- return {"success": False, "error": "httpx package not installed. Install with: pip install httpx"}
66
+ return {
67
+ "success": False,
68
+ "error": "httpx package not installed. Install with: pip install httpx",
69
+ }
66
70
 
67
71
  share_id = extract_share_id(share_url)
68
72
  if not share_id:
@@ -101,14 +105,16 @@ def download_share_data(
101
105
 
102
106
  # Download export data
103
107
  try:
104
- export_response = httpx.get(
105
- f"{backend_url}/api/share/{share_id}/export", headers=headers, timeout=30.0
106
- )
108
+ export_url = f"{backend_url}/api/share/{share_id}/export"
109
+ if cache_buster:
110
+ export_url = f"{export_url}?cache_bust={cache_buster}"
111
+
112
+ export_response = httpx.get(export_url, headers=headers, timeout=30.0)
107
113
  export_data = export_response.json()
108
114
 
109
115
  if export_response.status_code == 413 or export_data.get("needs_chunked_download"):
110
116
  total_chunks = export_data.get("total_chunks", 1)
111
- raw_data = _download_chunks(backend_url, share_id, headers, total_chunks)
117
+ raw_data = _download_chunks(backend_url, share_id, headers, total_chunks, cache_buster)
112
118
  conversation_data = json.loads(raw_data)
113
119
  export_data = {
114
120
  "success": True,
@@ -494,7 +500,9 @@ def import_v2_data(
494
500
  share_url=share_url,
495
501
  commit_hashes=[],
496
502
  # V18: user identity (with backward compatibility for old format)
497
- created_by=event_data.get("created_by") or event_data.get("uid") or event_data.get("creator_id"),
503
+ created_by=event_data.get("created_by")
504
+ or event_data.get("uid")
505
+ or event_data.get("creator_id"),
498
506
  shared_by=config.uid, # Current user is the importer
499
507
  )
500
508
 
@@ -603,7 +611,9 @@ def import_session_with_turns(
603
611
  summary_locked_until=None,
604
612
  summary_error=None,
605
613
  # V18: user identity (with backward compatibility for old format)
606
- created_by=session_data.get("created_by") or session_data.get("uid") or session_data.get("creator_id"),
614
+ created_by=session_data.get("created_by")
615
+ or session_data.get("uid")
616
+ or session_data.get("creator_id"),
607
617
  shared_by=config.uid, # Current user is the importer
608
618
  )
609
619
 
@@ -833,7 +843,11 @@ def generate_content_hash(messages: List[Dict]) -> str:
833
843
 
834
844
 
835
845
  def _download_chunks(
836
- backend_url: str, share_id: str, headers: Dict[str, str], total_chunks: int
846
+ backend_url: str,
847
+ share_id: str,
848
+ headers: Dict[str, str],
849
+ total_chunks: int,
850
+ cache_buster: Optional[str] = None,
837
851
  ) -> str:
838
852
  """
839
853
  Download data in chunks and combine them.
@@ -854,8 +868,11 @@ def _download_chunks(
854
868
  task = progress.add_task("[cyan]Downloading chunks...", total=total_chunks)
855
869
 
856
870
  for i in range(total_chunks):
871
+ url = f"{backend_url}/api/share/{share_id}/export?chunk={i}"
872
+ if cache_buster:
873
+ url = f"{url}&cache_bust={cache_buster}"
857
874
  chunk_response = httpx.get(
858
- f"{backend_url}/api/share/{share_id}/export?chunk={i}",
875
+ url,
859
876
  headers=headers,
860
877
  timeout=60.0,
861
878
  )
@@ -876,8 +893,11 @@ def _download_chunks(
876
893
  for i in range(total_chunks):
877
894
  print(f"Downloading chunk {i + 1}/{total_chunks}...")
878
895
 
896
+ url = f"{backend_url}/api/share/{share_id}/export?chunk={i}"
897
+ if cache_buster:
898
+ url = f"{url}&cache_bust={cache_buster}"
879
899
  chunk_response = httpx.get(
880
- f"{backend_url}/api/share/{share_id}/export?chunk={i}",
900
+ url,
881
901
  headers=headers,
882
902
  timeout=60.0,
883
903
  )
realign/commands/init.py CHANGED
@@ -836,6 +836,22 @@ def init_command(
836
836
  console.print(f" Tmux: [cyan]{result.get('tmux_conf', 'N/A')}[/cyan]")
837
837
  console.print(f" Skills: [cyan]{result.get('skills_path', 'N/A')}[/cyan]")
838
838
 
839
+ # Codex compatibility note (best-effort).
840
+ # We rely on the Rust Codex CLI notify hook to avoid expensive polling. If the installed
841
+ # Codex binary is legacy/unsupported, warn and suggest upgrading.
842
+ try:
843
+ from ..codex_hooks.notify_hook_installer import codex_cli_supports_notify_hook
844
+
845
+ supported = codex_cli_supports_notify_hook()
846
+ if supported is False:
847
+ console.print("\n[yellow]![/yellow] Codex CLI detected but does not support notify hook.")
848
+ console.print(
849
+ "[dim]Tip: update to the Rust Codex CLI to enable reliable, event-driven Codex imports (no polling).[/dim]"
850
+ )
851
+ # If Codex isn't installed (None), stay silent.
852
+ except Exception:
853
+ pass
854
+
839
855
  hooks_installed = result.get("hooks_installed") or []
840
856
  if hooks_installed:
841
857
  console.print(f" Hooks: [cyan]{', '.join(hooks_installed)}[/cyan]")