aline-ai 0.6.3__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.
@@ -0,0 +1,495 @@
1
+ """Aline doctor command - Repair common issues after updates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import io
7
+ import shutil
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, Tuple
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from ..config import ReAlignConfig, get_default_config_content
16
+
17
+ console = Console()
18
+
19
+
20
+ def _clear_python_cache(root: Path, *, verbose: bool) -> Tuple[int, int]:
21
+ pyc_count = 0
22
+ pycache_count = 0
23
+
24
+ for pyc_file in root.rglob("*.pyc"):
25
+ try:
26
+ pyc_file.unlink()
27
+ pyc_count += 1
28
+ if verbose:
29
+ console.print(f" [dim]Removed: {pyc_file}[/dim]")
30
+ except Exception as e:
31
+ if verbose:
32
+ console.print(f" [yellow]Failed to remove {pyc_file}: {e}[/yellow]")
33
+
34
+ for pycache_dir in root.rglob("__pycache__"):
35
+ if not pycache_dir.is_dir():
36
+ continue
37
+ try:
38
+ shutil.rmtree(pycache_dir)
39
+ pycache_count += 1
40
+ if verbose:
41
+ console.print(f" [dim]Removed: {pycache_dir}[/dim]")
42
+ except Exception as e:
43
+ if verbose:
44
+ console.print(f" [yellow]Failed to remove {pycache_dir}: {e}[/yellow]")
45
+
46
+ return pyc_count, pycache_count
47
+
48
+
49
+ def _ensure_global_config(*, force: bool, verbose: bool) -> Path:
50
+ config_path = Path.home() / ".aline" / "config.yaml"
51
+
52
+ if force or not config_path.exists():
53
+ config_path.parent.mkdir(parents=True, exist_ok=True)
54
+ config_path.write_text(get_default_config_content(), encoding="utf-8")
55
+ if verbose:
56
+ console.print(f" [dim]Wrote config: {config_path}[/dim]")
57
+
58
+ return config_path
59
+
60
+
61
+ def _ensure_database_initialized(config: ReAlignConfig, *, verbose: bool) -> Path:
62
+ db_path = Path(config.sqlite_db_path).expanduser()
63
+ db_path.parent.mkdir(parents=True, exist_ok=True)
64
+
65
+ from ..db.sqlite_db import SQLiteDatabase
66
+
67
+ db = SQLiteDatabase(str(db_path))
68
+ ok = db.initialize()
69
+ db.close()
70
+ if verbose:
71
+ console.print(f" [dim]Database init: {'ok' if ok else 'failed'}[/dim]")
72
+
73
+ return db_path
74
+
75
+
76
+ def _update_claude_hooks(*, verbose: bool) -> Tuple[list[str], list[str]]:
77
+ hooks_updated: list[str] = []
78
+ hooks_failed: list[str] = []
79
+
80
+ # Stop hook
81
+ try:
82
+ from ..claude_hooks.stop_hook_installer import install_stop_hook, get_settings_path
83
+
84
+ if install_stop_hook(get_settings_path(), quiet=True, force=True):
85
+ hooks_updated.append("Stop")
86
+ if verbose:
87
+ console.print(" [dim]Stop hook updated[/dim]")
88
+ else:
89
+ hooks_failed.append("Stop")
90
+ except Exception as e:
91
+ hooks_failed.append("Stop")
92
+ if verbose:
93
+ console.print(f" [yellow]Stop hook failed: {e}[/yellow]")
94
+
95
+ # UserPromptSubmit hook
96
+ try:
97
+ from ..claude_hooks.user_prompt_submit_hook_installer import (
98
+ install_user_prompt_submit_hook,
99
+ get_settings_path as get_submit_settings_path,
100
+ )
101
+
102
+ if install_user_prompt_submit_hook(get_submit_settings_path(), quiet=True, force=True):
103
+ hooks_updated.append("UserPromptSubmit")
104
+ if verbose:
105
+ console.print(" [dim]UserPromptSubmit hook updated[/dim]")
106
+ else:
107
+ hooks_failed.append("UserPromptSubmit")
108
+ except Exception as e:
109
+ hooks_failed.append("UserPromptSubmit")
110
+ if verbose:
111
+ console.print(f" [yellow]UserPromptSubmit hook failed: {e}[/yellow]")
112
+
113
+ # PermissionRequest hook
114
+ try:
115
+ from ..claude_hooks.permission_request_hook_installer import (
116
+ install_permission_request_hook,
117
+ get_settings_path as get_permission_settings_path,
118
+ )
119
+
120
+ if install_permission_request_hook(get_permission_settings_path(), quiet=True, force=True):
121
+ hooks_updated.append("PermissionRequest")
122
+ if verbose:
123
+ console.print(" [dim]PermissionRequest hook updated[/dim]")
124
+ else:
125
+ hooks_failed.append("PermissionRequest")
126
+ except Exception as e:
127
+ hooks_failed.append("PermissionRequest")
128
+ if verbose:
129
+ console.print(f" [yellow]PermissionRequest hook failed: {e}[/yellow]")
130
+
131
+ return hooks_updated, hooks_failed
132
+
133
+
134
+ def _update_skills(*, verbose: bool) -> int:
135
+ from .add import add_skills_command
136
+
137
+ stdout_capture = io.StringIO()
138
+ with contextlib.redirect_stdout(stdout_capture):
139
+ add_skills_command(force=True)
140
+
141
+ output = stdout_capture.getvalue()
142
+ updated_count = output.count("✓")
143
+ if verbose and output.strip():
144
+ for line in output.strip().split("\n"):
145
+ console.print(f" [dim]{line}[/dim]")
146
+ return updated_count
147
+
148
+
149
+ def _check_failed_jobs(
150
+ config: ReAlignConfig,
151
+ *,
152
+ verbose: bool,
153
+ fix: bool,
154
+ ) -> Tuple[int, int]:
155
+ """
156
+ Check for failed turn_summary and session_summary jobs.
157
+
158
+ Returns:
159
+ (failed_count, requeued_count)
160
+ """
161
+ from ..db.sqlite_db import SQLiteDatabase
162
+
163
+ db_path = Path(config.sqlite_db_path).expanduser()
164
+ if not db_path.exists():
165
+ return 0, 0
166
+
167
+ db = SQLiteDatabase(str(db_path))
168
+
169
+ try:
170
+ # Count failed jobs in queue
171
+ failed_turn_count = db.count_jobs(kinds=["turn_summary"], statuses=["failed"])
172
+ failed_session_count = db.count_jobs(kinds=["session_summary"], statuses=["failed"])
173
+ total_failed = failed_turn_count + failed_session_count
174
+
175
+ if verbose and total_failed > 0:
176
+ # Show details of failed jobs
177
+ failed_jobs = db.list_jobs(statuses=["failed"], limit=20)
178
+ if failed_jobs:
179
+ table = Table(title="Failed Jobs in Queue", show_header=True, header_style="bold")
180
+ table.add_column("Kind", style="cyan")
181
+ table.add_column("Session", style="dim")
182
+ table.add_column("Turn", style="dim")
183
+ table.add_column("Error", style="red", max_width=40)
184
+ table.add_column("Attempts", justify="right")
185
+
186
+ for job in failed_jobs:
187
+ payload = job.get("payload", {})
188
+ session_id = str(payload.get("session_id", ""))[:8]
189
+ turn_num = str(payload.get("turn_number", "-"))
190
+ error = (job.get("last_error") or "")[:40]
191
+ attempts = str(job.get("attempts", 0))
192
+ table.add_row(job["kind"], session_id, turn_num, error, attempts)
193
+
194
+ console.print(table)
195
+
196
+ requeued = 0
197
+ if fix and total_failed > 0:
198
+ # Requeue failed jobs
199
+ requeued, _ = db.requeue_failed_jobs(kinds=["turn_summary", "session_summary"])
200
+
201
+ return total_failed, requeued
202
+
203
+ finally:
204
+ db.close()
205
+
206
+
207
+ def _check_llm_error_turns(
208
+ config: ReAlignConfig,
209
+ *,
210
+ verbose: bool,
211
+ fix: bool,
212
+ ) -> Tuple[int, int]:
213
+ """
214
+ Check for turns with LLM API errors (llm_title contains 'LLM API Error').
215
+
216
+ Returns:
217
+ (error_count, requeued_count)
218
+ """
219
+ from ..db.sqlite_db import SQLiteDatabase
220
+
221
+ db_path = Path(config.sqlite_db_path).expanduser()
222
+ if not db_path.exists():
223
+ return 0, 0
224
+
225
+ db = SQLiteDatabase(str(db_path))
226
+
227
+ try:
228
+ conn = db._get_connection()
229
+
230
+ # Find turns with LLM API Error marker (exact prefix match)
231
+ rows = conn.execute(
232
+ """
233
+ SELECT t.session_id, t.turn_number, t.llm_title, s.session_file_path, s.workspace_path
234
+ FROM turns t
235
+ JOIN sessions s ON t.session_id = s.id
236
+ WHERE t.llm_title LIKE '⚠ LLM API Error%'
237
+ ORDER BY t.timestamp DESC
238
+ """
239
+ ).fetchall()
240
+
241
+ if not rows:
242
+ return 0, 0
243
+
244
+ if verbose:
245
+ table = Table(title="Turns with LLM API Error", show_header=True, header_style="bold")
246
+ table.add_column("Session", style="dim")
247
+ table.add_column("Turn", justify="right")
248
+ table.add_column("Title", style="yellow", max_width=50)
249
+
250
+ for row in rows[:20]: # Show max 20
251
+ session_id = str(row["session_id"])[:12]
252
+ turn_num = str(row["turn_number"])
253
+ title = (row["llm_title"] or "")[:50]
254
+ table.add_row(session_id, turn_num, title)
255
+
256
+ if len(rows) > 20:
257
+ table.add_row("...", "...", f"({len(rows) - 20} more)")
258
+
259
+ console.print(table)
260
+
261
+ if not fix:
262
+ return len(rows), 0
263
+
264
+ # Requeue turn_summary jobs for these turns
265
+ requeued = 0
266
+ skipped = 0
267
+ for row in rows:
268
+ session_file_path_str = row["session_file_path"] or ""
269
+
270
+ # Skip invalid session file paths
271
+ if not session_file_path_str or session_file_path_str in (".", ".."):
272
+ if verbose:
273
+ console.print(f" [dim]Skip: invalid session path for {row['session_id'][:8]} #{row['turn_number']}[/dim]")
274
+ skipped += 1
275
+ continue
276
+
277
+ session_file_path = Path(session_file_path_str)
278
+ workspace_path = Path(row["workspace_path"]) if row["workspace_path"] else None
279
+
280
+ if not session_file_path.exists():
281
+ if verbose:
282
+ console.print(f" [dim]Skip: session file not found: {session_file_path}[/dim]")
283
+ skipped += 1
284
+ continue
285
+
286
+ try:
287
+ db.enqueue_turn_summary_job(
288
+ session_file_path=session_file_path,
289
+ workspace_path=workspace_path,
290
+ turn_number=row["turn_number"],
291
+ skip_dedup=True, # Force regeneration
292
+ )
293
+ requeued += 1
294
+ except Exception as e:
295
+ if verbose:
296
+ console.print(f" [yellow]Failed to enqueue {row['session_id'][:8]} #{row['turn_number']}: {e}[/yellow]")
297
+ skipped += 1
298
+
299
+ return len(rows), requeued
300
+
301
+ finally:
302
+ db.close()
303
+
304
+
305
+ def run_doctor(
306
+ *,
307
+ restart_daemons: bool,
308
+ start_if_not_running: bool,
309
+ verbose: bool,
310
+ clear_cache: bool,
311
+ ) -> int:
312
+ """
313
+ Core doctor logic.
314
+
315
+ Args:
316
+ restart_daemons: Restart/ensure daemons at the end.
317
+ start_if_not_running: If True and restart_daemons is True, start daemons even if not running.
318
+ verbose: Print details.
319
+ clear_cache: Clear Python bytecode cache for the installed package directory.
320
+ """
321
+ from ..auth import is_logged_in
322
+ from . import watcher as watcher_cmd
323
+ from . import worker as worker_cmd
324
+ from . import init as init_cmd
325
+
326
+ console.print("\n[bold blue]═══ Aline Doctor ═══[/bold blue]\n")
327
+
328
+ watcher_running, _watcher_pid, _watcher_mode = watcher_cmd.detect_watcher_process()
329
+ worker_running, _worker_pid, _worker_mode = worker_cmd.detect_worker_process()
330
+
331
+ can_start_daemons = is_logged_in()
332
+ if restart_daemons and not can_start_daemons:
333
+ console.print("[yellow]![/yellow] Not logged in; skipping daemon restart/start.")
334
+ console.print("[dim]Run 'aline login' then re-run 'aline doctor'.[/dim]")
335
+ restart_daemons = False
336
+
337
+ # Stop daemons early to avoid DB lock during migrations.
338
+ if restart_daemons:
339
+ if watcher_running:
340
+ if verbose:
341
+ console.print("[dim]Stopping watcher...[/dim]")
342
+ try:
343
+ watcher_cmd.watcher_stop_command()
344
+ except Exception:
345
+ pass
346
+ if worker_running:
347
+ if verbose:
348
+ console.print("[dim]Stopping worker...[/dim]")
349
+ try:
350
+ worker_cmd.worker_stop_command()
351
+ except Exception:
352
+ pass
353
+
354
+ # 1. Clear Python cache (package scope)
355
+ if clear_cache:
356
+ console.print("[bold]1. Clearing Python cache...[/bold]")
357
+ package_root = Path(__file__).resolve().parents[1]
358
+ pyc_count, pycache_count = _clear_python_cache(package_root, verbose=verbose)
359
+ console.print(
360
+ f" [green]✓[/green] Cleared {pyc_count} .pyc files, {pycache_count} __pycache__ directories"
361
+ )
362
+
363
+ # 2. Ensure global environment (config/db/prompts/tmux)
364
+ console.print("\n[bold]2. Ensuring global environment...[/bold]")
365
+ try:
366
+ config_path = _ensure_global_config(force=False, verbose=verbose)
367
+ config = ReAlignConfig.load(config_path)
368
+ db_path = _ensure_database_initialized(config, verbose=verbose)
369
+
370
+ # Prompts + tmux are safe to re-run (no overwrite for prompts; tmux auto-updates Aline-managed config).
371
+ init_cmd._initialize_prompts_directory()
372
+ tmux_conf = init_cmd._initialize_tmux_config()
373
+
374
+ console.print(f" [green]✓[/green] Config: {config_path}")
375
+ console.print(f" [green]✓[/green] Database: {db_path}")
376
+ console.print(f" [green]✓[/green] Prompts: {Path.home() / '.aline' / 'prompts'}")
377
+ console.print(f" [green]✓[/green] Tmux: {tmux_conf}")
378
+ except Exception as e:
379
+ console.print(f" [red]✗[/red] Failed to ensure global environment: {e}")
380
+ return 1
381
+
382
+ # 3. Update Claude Code hooks
383
+ console.print("\n[bold]3. Updating Claude Code hooks...[/bold]")
384
+ hooks_updated, hooks_failed = _update_claude_hooks(verbose=verbose)
385
+ if hooks_updated:
386
+ console.print(f" [green]✓[/green] Updated hooks: {', '.join(hooks_updated)}")
387
+ if hooks_failed:
388
+ console.print(f" [yellow]![/yellow] Failed hooks: {', '.join(hooks_failed)}")
389
+
390
+ # 4. Update skills
391
+ console.print("\n[bold]4. Updating skills...[/bold]")
392
+ try:
393
+ updated_count = _update_skills(verbose=verbose)
394
+ if updated_count > 0:
395
+ console.print(f" [green]✓[/green] Updated {updated_count} skill(s)")
396
+ else:
397
+ console.print(" [green]✓[/green] Skills are up to date")
398
+ except Exception as e:
399
+ console.print(f" [yellow]![/yellow] Failed to update skills: {e}")
400
+
401
+ # 5. Check/fix failed jobs and LLM error turns
402
+ console.print("\n[bold]5. Checking failed summary jobs...[/bold]")
403
+ try:
404
+ # First pass: check counts without fixing
405
+ failed_count, _ = _check_failed_jobs(config, verbose=verbose, fix=False)
406
+ llm_error_count, _ = _check_llm_error_turns(config, verbose=verbose, fix=False)
407
+
408
+ total_issues = failed_count + llm_error_count
409
+
410
+ if total_issues == 0:
411
+ console.print(" [green]✓[/green] No failed jobs or LLM errors found")
412
+ else:
413
+ # Show what was found
414
+ if failed_count > 0:
415
+ console.print(f" [yellow]![/yellow] Found {failed_count} failed job(s) in queue")
416
+ if llm_error_count > 0:
417
+ console.print(f" [yellow]![/yellow] Found {llm_error_count} turn(s) with LLM API errors")
418
+
419
+ # Ask user if they want to fix
420
+ if typer.confirm("\n Do you want to requeue these for regeneration?", default=True):
421
+ requeued_jobs = 0
422
+ requeued_turns = 0
423
+
424
+ if failed_count > 0:
425
+ _, requeued_jobs = _check_failed_jobs(config, verbose=verbose, fix=True)
426
+ if llm_error_count > 0:
427
+ _, requeued_turns = _check_llm_error_turns(config, verbose=verbose, fix=True)
428
+
429
+ total_requeued = requeued_jobs + requeued_turns
430
+ console.print(f" [green]✓[/green] Requeued {total_requeued} item(s) for regeneration")
431
+ else:
432
+ console.print(" [dim]Skipped fixing failed jobs[/dim]")
433
+ except Exception as e:
434
+ console.print(f" [yellow]![/yellow] Failed to check jobs: {e}")
435
+
436
+ # 6. Restart/ensure daemons
437
+ if restart_daemons:
438
+ console.print("\n[bold]6. Checking daemons...[/bold]")
439
+
440
+ should_start_watcher = watcher_running or start_if_not_running
441
+ should_start_worker = worker_running or start_if_not_running
442
+
443
+ if should_start_watcher:
444
+ try:
445
+ exit_code = watcher_cmd.watcher_start_command()
446
+ if exit_code == 0:
447
+ console.print(" [green]✓[/green] Watcher is running")
448
+ else:
449
+ console.print(" [yellow]![/yellow] Failed to start watcher")
450
+ except Exception as e:
451
+ console.print(f" [yellow]![/yellow] Failed to start watcher: {e}")
452
+ else:
453
+ console.print(" [dim]Watcher was not running; leaving it stopped.[/dim]")
454
+
455
+ if should_start_worker:
456
+ try:
457
+ exit_code = worker_cmd.worker_start_command()
458
+ if exit_code == 0:
459
+ console.print(" [green]✓[/green] Worker is running")
460
+ else:
461
+ console.print(" [yellow]![/yellow] Failed to start worker")
462
+ except Exception as e:
463
+ console.print(f" [yellow]![/yellow] Failed to start worker: {e}")
464
+ else:
465
+ console.print(" [dim]Worker was not running; leaving it stopped.[/dim]")
466
+ else:
467
+ console.print("\n[dim]Skipping daemon restart (--no-restart)[/dim]")
468
+
469
+ console.print("\n[green]Done![/green] Aline is ready with the latest code.")
470
+ return 0
471
+
472
+
473
+ def doctor_command(
474
+ no_restart: bool = typer.Option(False, "--no-restart", help="Only repair files, don't restart daemons"),
475
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
476
+ ):
477
+ """
478
+ Fix common issues after code updates.
479
+
480
+ This command:
481
+ - Clears Python bytecode cache for the installed Aline package
482
+ - Ensures global config/DB/prompts/tmux are present and up to date
483
+ - Updates Claude Code hooks (Stop, UserPromptSubmit, PermissionRequest)
484
+ - Updates Claude Code skills to the latest version
485
+ - Checks for failed summary jobs (prompts to fix if found)
486
+ - Restarts watcher/worker (default) so long-running processes use the latest code
487
+ """
488
+ exit_code = run_doctor(
489
+ restart_daemons=not no_restart,
490
+ start_if_not_running=True,
491
+ verbose=verbose,
492
+ clear_cache=True,
493
+ )
494
+ raise typer.Exit(code=exit_code)
495
+
realign/commands/init.py CHANGED
@@ -1,6 +1,8 @@
1
1
  """ReAlign init command - Initialize ReAlign tracking system."""
2
2
 
3
- from typing import Dict, Any, Optional, Tuple
3
+ import shutil
4
+ import sys
5
+ from typing import Annotated, Any, Dict, Optional, Tuple
4
6
  from pathlib import Path
5
7
  import re
6
8
  import typer
@@ -706,9 +708,21 @@ def init_global(
706
708
 
707
709
 
708
710
  def init_command(
709
- force: bool = typer.Option(
710
- False, "--force", "-f", help="Overwrite global config with defaults"
711
- ),
711
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite global config with defaults"),
712
+ doctor: Annotated[
713
+ bool,
714
+ typer.Option(
715
+ "--doctor/--no-doctor",
716
+ help="Run 'aline doctor' after init (best for upgrades)",
717
+ ),
718
+ ] = False,
719
+ install_tmux: Annotated[
720
+ bool,
721
+ typer.Option(
722
+ "--install-tmux/--no-install-tmux",
723
+ help="Auto-install tmux via Homebrew if missing (macOS only)",
724
+ ),
725
+ ] = True,
712
726
  start_watcher: Optional[bool] = typer.Option(
713
727
  None,
714
728
  "--start-watcher/--no-start-watcher",
@@ -726,6 +740,54 @@ def init_command(
726
740
  force=force,
727
741
  )
728
742
 
743
+ # First-time UX: tmux is required for the default dashboard experience (tmux mode).
744
+ # Only attempt on macOS; on other platforms, leave it to user.
745
+ if (
746
+ result.get("success")
747
+ and install_tmux
748
+ and result.get("tmux_conf")
749
+ and sys.platform == "darwin"
750
+ and shutil.which("tmux") is None
751
+ ):
752
+ console.print("\n[bold]tmux not found. Installing via Homebrew...[/bold]")
753
+ try:
754
+ from . import add as add_cmd
755
+
756
+ rc = add_cmd.add_tmux_command(install_brew=True)
757
+ if rc != 0:
758
+ result["errors"] = (result.get("errors") or []) + [
759
+ "tmux install failed (required for the default tmux dashboard)",
760
+ "Tip: set ALINE_TERMINAL_MODE=native to run without tmux",
761
+ ]
762
+ except Exception as e:
763
+ result["errors"] = (result.get("errors") or []) + [
764
+ f"tmux install failed: {e}",
765
+ "Tip: set ALINE_TERMINAL_MODE=native to run without tmux",
766
+ ]
767
+
768
+ if doctor and result.get("success"):
769
+ # Run doctor in "safe" mode: restart only if already running, and keep init fast.
770
+ try:
771
+ from . import doctor as doctor_cmd
772
+
773
+ restart_daemons = start_watcher is not False
774
+ doctor_exit = doctor_cmd.run_doctor(
775
+ restart_daemons=restart_daemons,
776
+ start_if_not_running=False,
777
+ verbose=False,
778
+ clear_cache=False,
779
+ )
780
+ if doctor_exit != 0:
781
+ result["success"] = False
782
+ result["errors"] = (result.get("errors") or []) + [
783
+ "aline doctor failed (see output above)"
784
+ ]
785
+ result["message"] = f"{result.get('message', '').strip()} (doctor failed)".strip()
786
+ except Exception as e:
787
+ result["success"] = False
788
+ result["errors"] = (result.get("errors") or []) + [f"aline doctor failed: {e}"]
789
+ result["message"] = f"{result.get('message', '').strip()} (doctor failed)".strip()
790
+
729
791
  watcher_started: Optional[bool] = None
730
792
  watcher_start_exit: Optional[int] = None
731
793
  worker_started: Optional[bool] = None
@@ -1443,7 +1443,8 @@ def watcher_session_list_command(
1443
1443
  console.print("[yellow]No sessions discovered.[/yellow]")
1444
1444
  console.print("[dim]Sessions are discovered from:[/dim]")
1445
1445
  console.print("[dim] • Claude Code: ~/.claude/projects/[/dim]")
1446
- console.print("[dim] • Codex: ~/.codex/sessions/[/dim]")
1446
+ console.print("[dim] • Codex (legacy): ~/.codex/sessions/[/dim]")
1447
+ console.print("[dim] • Codex (isolated): ~/.aline/codex_homes/*/sessions/[/dim]")
1447
1448
  console.print("[dim] • Gemini: ~/.gemini/tmp/*/chats/[/dim]")
1448
1449
  console.print("[dim] • Imported shares: Database[/dim]")
1449
1450
  return 0
realign/config.py CHANGED
@@ -33,6 +33,10 @@ class ReAlignConfig:
33
33
  # Session catch-up settings
34
34
  max_catchup_sessions: int = 3 # Max sessions to auto-import on watcher startup
35
35
 
36
+ # Terminal auto-close settings
37
+ auto_close_stale_terminals: bool = False # Auto-close terminals inactive for 24+ hours
38
+ stale_terminal_hours: int = 24 # Hours of inactivity before auto-closing
39
+
36
40
  @classmethod
37
41
  def load(cls, config_path: Optional[Path] = None) -> "ReAlignConfig":
38
42
  """Load configuration from file with environment variable overrides."""
@@ -81,11 +85,13 @@ class ReAlignConfig:
81
85
  "user_name": os.getenv("REALIGN_USER_NAME"),
82
86
  "uid": os.getenv("REALIGN_UID"),
83
87
  "max_catchup_sessions": os.getenv("REALIGN_MAX_CATCHUP_SESSIONS"),
88
+ "auto_close_stale_terminals": os.getenv("REALIGN_AUTO_CLOSE_STALE_TERMINALS"),
89
+ "stale_terminal_hours": os.getenv("REALIGN_STALE_TERMINAL_HOURS"),
84
90
  }
85
91
 
86
92
  for key, value in env_overrides.items():
87
93
  if value is not None:
88
- if key in ["summary_max_chars", "max_catchup_sessions"]:
94
+ if key in ["summary_max_chars", "max_catchup_sessions", "stale_terminal_hours"]:
89
95
  config_dict[key] = int(value)
90
96
  elif key in [
91
97
  "redact_on_match",
@@ -95,6 +101,7 @@ class ReAlignConfig:
95
101
  "auto_detect_gemini",
96
102
  "mcp_auto_commit",
97
103
  "enable_temp_turn_titles",
104
+ "auto_close_stale_terminals",
98
105
  ]:
99
106
  config_dict[key] = value.lower() in ("true", "1", "yes")
100
107
  else:
@@ -132,6 +139,8 @@ class ReAlignConfig:
132
139
  "user_name": self.user_name,
133
140
  "uid": self.uid,
134
141
  "max_catchup_sessions": self.max_catchup_sessions,
142
+ "auto_close_stale_terminals": self.auto_close_stale_terminals,
143
+ "stale_terminal_hours": self.stale_terminal_hours,
135
144
  }
136
145
 
137
146
  with open(config_path, "w", encoding="utf-8") as f:
realign/dashboard/app.py CHANGED
@@ -323,20 +323,22 @@ class AlineDashboard(App):
323
323
  self.push_screen(ShareImportScreen())
324
324
 
325
325
  async def action_load_context(self) -> None:
326
- """Load selected sessions/events into the active Claude terminal context."""
326
+ """Load selected sessions/events into the active terminal context (Claude/Codex)."""
327
327
  tabbed_content = self.query_one(TabbedContent)
328
328
  active_tab_id = tabbed_content.active
329
329
 
330
330
  try:
331
331
  from . import tmux_manager
332
332
 
333
- context_id = tmux_manager.get_active_claude_context_id()
333
+ context_id = tmux_manager.get_active_context_id(
334
+ allowed_providers={"claude", "codex"}
335
+ )
334
336
  except Exception:
335
337
  context_id = None
336
338
 
337
339
  if not context_id:
338
340
  self.notify(
339
- "No active Claude context found. Use the Terminal tab and select a 'cc' terminal (New cc).",
341
+ "No active context found. Use the Terminal tab and select a Claude ('cc') or Codex terminal.",
340
342
  title="Load Context",
341
343
  severity="warning",
342
344
  timeout=4,