aline-ai 0.5.3__py3-none-any.whl → 0.5.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.
Files changed (80) hide show
  1. {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/METADATA +1 -1
  2. aline_ai-0.5.5.dist-info/RECORD +93 -0
  3. realign/__init__.py +1 -1
  4. realign/adapters/antigravity.py +28 -20
  5. realign/adapters/base.py +46 -50
  6. realign/adapters/claude.py +14 -14
  7. realign/adapters/codex.py +7 -7
  8. realign/adapters/gemini.py +11 -11
  9. realign/adapters/registry.py +14 -10
  10. realign/claude_detector.py +2 -2
  11. realign/claude_hooks/__init__.py +3 -3
  12. realign/claude_hooks/permission_request_hook.py +35 -0
  13. realign/claude_hooks/permission_request_hook_installer.py +31 -32
  14. realign/claude_hooks/stop_hook.py +4 -1
  15. realign/claude_hooks/stop_hook_installer.py +30 -31
  16. realign/cli.py +24 -0
  17. realign/codex_detector.py +11 -11
  18. realign/commands/add.py +361 -35
  19. realign/commands/config.py +3 -12
  20. realign/commands/context.py +3 -1
  21. realign/commands/export_shares.py +86 -127
  22. realign/commands/import_shares.py +145 -155
  23. realign/commands/init.py +166 -30
  24. realign/commands/restore.py +18 -6
  25. realign/commands/search.py +14 -42
  26. realign/commands/upgrade.py +155 -11
  27. realign/commands/watcher.py +98 -219
  28. realign/commands/worker.py +29 -6
  29. realign/config.py +25 -20
  30. realign/context.py +1 -3
  31. realign/dashboard/app.py +4 -4
  32. realign/dashboard/screens/create_event.py +3 -1
  33. realign/dashboard/screens/event_detail.py +14 -6
  34. realign/dashboard/screens/session_detail.py +3 -1
  35. realign/dashboard/screens/share_import.py +7 -3
  36. realign/dashboard/tmux_manager.py +91 -22
  37. realign/dashboard/widgets/config_panel.py +85 -1
  38. realign/dashboard/widgets/events_table.py +3 -1
  39. realign/dashboard/widgets/header.py +1 -0
  40. realign/dashboard/widgets/search_panel.py +37 -27
  41. realign/dashboard/widgets/sessions_table.py +24 -15
  42. realign/dashboard/widgets/terminal_panel.py +207 -17
  43. realign/dashboard/widgets/watcher_panel.py +6 -2
  44. realign/dashboard/widgets/worker_panel.py +10 -1
  45. realign/db/__init__.py +1 -1
  46. realign/db/base.py +5 -15
  47. realign/db/locks.py +0 -1
  48. realign/db/migration.py +82 -76
  49. realign/db/schema.py +2 -6
  50. realign/db/sqlite_db.py +23 -41
  51. realign/events/__init__.py +0 -1
  52. realign/events/event_summarizer.py +27 -15
  53. realign/events/session_summarizer.py +29 -15
  54. realign/file_lock.py +1 -0
  55. realign/hooks.py +150 -60
  56. realign/logging_config.py +12 -15
  57. realign/mcp_server.py +30 -51
  58. realign/mcp_watcher.py +0 -1
  59. realign/models/event.py +29 -20
  60. realign/prompts/__init__.py +7 -7
  61. realign/prompts/presets.py +15 -11
  62. realign/redactor.py +99 -59
  63. realign/triggers/__init__.py +9 -9
  64. realign/triggers/antigravity_trigger.py +30 -28
  65. realign/triggers/base.py +4 -3
  66. realign/triggers/claude_trigger.py +104 -85
  67. realign/triggers/codex_trigger.py +15 -5
  68. realign/triggers/gemini_trigger.py +57 -47
  69. realign/triggers/next_turn_trigger.py +3 -1
  70. realign/triggers/registry.py +6 -2
  71. realign/triggers/turn_status.py +3 -1
  72. realign/watcher_core.py +306 -131
  73. realign/watcher_daemon.py +8 -8
  74. realign/worker_core.py +3 -1
  75. realign/worker_daemon.py +3 -1
  76. aline_ai-0.5.3.dist-info/RECORD +0 -93
  77. {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/WHEEL +0 -0
  78. {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/entry_points.txt +0 -0
  79. {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/licenses/LICENSE +0 -0
  80. {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/top_level.txt +0 -0
@@ -87,7 +87,11 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
87
87
  # Show job counts if DB available
88
88
  try:
89
89
  dbi = get_database()
90
- db_path = Path(str(getattr(dbi, "db_path", ""))).expanduser() if isinstance(dbi, SQLiteDatabase) else None
90
+ db_path = (
91
+ Path(str(getattr(dbi, "db_path", ""))).expanduser()
92
+ if isinstance(dbi, SQLiteDatabase)
93
+ else None
94
+ )
91
95
  if isinstance(dbi, SQLiteDatabase):
92
96
  counts = dbi.get_job_counts()
93
97
  turn_jobs = dbi.list_jobs(limit=30, kinds=["turn_summary"])
@@ -116,6 +120,7 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
116
120
  job_kinds = {}
117
121
 
118
122
  if json_output:
123
+
119
124
  def _session_id_from_job(job: dict) -> str:
120
125
  payload = job.get("payload") or {}
121
126
  session_id = str(payload.get("session_id") or "").strip()
@@ -133,7 +138,11 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
133
138
  session_type_by_id: dict[str, str] = {}
134
139
  if isinstance(dbi, SQLiteDatabase):
135
140
  session_ids = sorted(
136
- {sid for sid in (_session_id_from_job(j) for j in (turn_jobs + session_jobs)) if sid}
141
+ {
142
+ sid
143
+ for sid in (_session_id_from_job(j) for j in (turn_jobs + session_jobs))
144
+ if sid
145
+ }
137
146
  )
138
147
  if session_ids:
139
148
  for s in dbi.get_sessions_by_ids(session_ids):
@@ -196,7 +205,12 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
196
205
  }
197
206
 
198
207
  data = {
199
- "worker": {"status": status.lower(), "running": is_running, "pid": pid, "mode": mode},
208
+ "worker": {
209
+ "status": status.lower(),
210
+ "running": is_running,
211
+ "pid": pid,
212
+ "mode": mode,
213
+ },
200
214
  "db_path": str(db_path) if db_path else None,
201
215
  "job_counts": counts,
202
216
  "job_kinds": job_kinds,
@@ -206,7 +220,9 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
206
220
  print(json.dumps(data, ensure_ascii=True))
207
221
  return 0
208
222
 
209
- console.print(f"[bold]Worker:[/bold] {status}" + (f" (PID: {pid}, mode: {mode})" if pid else ""))
223
+ console.print(
224
+ f"[bold]Worker:[/bold] {status}" + (f" (PID: {pid}, mode: {mode})" if pid else "")
225
+ )
210
226
  if db_path:
211
227
  console.print(f"[dim]DB:[/dim] {db_path}")
212
228
  if counts:
@@ -225,6 +241,7 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
225
241
  console.print(f"[dim]Job kinds:[/dim] {pairs}")
226
242
 
227
243
  try:
244
+
228
245
  def fmt_status(s: str) -> tuple[str, str]:
229
246
  s = (s or "").lower()
230
247
  if s == "processing":
@@ -254,7 +271,11 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
254
271
  session_type_by_id: dict[str, str] = {}
255
272
  if isinstance(dbi, SQLiteDatabase):
256
273
  session_ids = sorted(
257
- {sid for sid in (_session_id_from_job(j) for j in (turn_jobs + session_jobs)) if sid}
274
+ {
275
+ sid
276
+ for sid in (_session_id_from_job(j) for j in (turn_jobs + session_jobs))
277
+ if sid
278
+ }
258
279
  )
259
280
  if session_ids:
260
281
  for s in dbi.get_sessions_by_ids(session_ids):
@@ -501,7 +522,9 @@ def worker_stop_command() -> int:
501
522
  pid, mode = all_processes[0]
502
523
  console.print(f"[cyan]Stopping worker (PID: {pid}, mode: {mode})...[/cyan]")
503
524
  else:
504
- console.print(f"[cyan]Found {len(all_processes)} worker processes, stopping all...[/cyan]")
525
+ console.print(
526
+ f"[cyan]Found {len(all_processes)} worker processes, stopping all...[/cyan]"
527
+ )
505
528
 
506
529
  for pid, _mode in all_processes:
507
530
  try:
realign/config.py CHANGED
@@ -12,36 +12,38 @@ class ReAlignConfig:
12
12
  """ReAlign configuration."""
13
13
 
14
14
  summary_max_chars: int = 500
15
- redact_on_match: bool = False # Default: disable redaction (can be enabled in config)
15
+ redact_on_match: bool = False # Default: disable redaction (can be enabled in config)
16
16
  hooks_installation: str = "repo"
17
17
  sqlite_db_path: str = "~/.aline/db/aline.db" # Path to SQLite database
18
18
  use_LLM: bool = True
19
- llm_provider: str = "auto" # LLM provider: "auto", "claude", or "openai"
19
+ llm_provider: str = "auto" # LLM provider: "auto", "claude", or "openai"
20
20
  auto_detect_claude: bool = True # Enable Claude Code session auto-detection
21
- auto_detect_codex: bool = True # Enable Codex session auto-detection
22
- auto_detect_gemini: bool = True # Enable Gemini CLI session auto-detection
23
- auto_detect_antigravity: bool = False # Enable Antigravity IDE brain artifact monitoring
24
- mcp_auto_commit: bool = True # Enable watcher auto-commit after each user request completes
21
+ auto_detect_codex: bool = True # Enable Codex session auto-detection
22
+ auto_detect_gemini: bool = True # Enable Gemini CLI session auto-detection
23
+ auto_detect_antigravity: bool = False # Enable Antigravity IDE brain artifact monitoring
24
+ mcp_auto_commit: bool = True # Enable watcher auto-commit after each user request completes
25
25
  enable_temp_turn_titles: bool = True # Generate temporary turn titles on user prompt submit
26
- share_backend_url: str = "https://realign-server.vercel.app" # Backend URL for interactive share export
26
+ share_backend_url: str = (
27
+ "https://realign-server.vercel.app" # Backend URL for interactive share export
28
+ )
27
29
 
28
30
  # User identity (V9)
29
- user_name: str = "" # User's display name (set during init)
30
- user_id: str = "" # User's UUID (generated from MAC address)
31
+ user_name: str = "" # User's display name (set during init)
32
+ user_id: str = "" # User's UUID (generated from MAC address)
31
33
 
32
34
  # Session catch-up settings
33
- max_catchup_sessions: int = 3 # Max sessions to auto-import on watcher startup
35
+ max_catchup_sessions: int = 3 # Max sessions to auto-import on watcher startup
34
36
 
35
37
  # LLM API Keys
36
- anthropic_api_key: Optional[str] = None # Anthropic API key (set in config, not environment)
37
- openai_api_key: Optional[str] = None # OpenAI API key (set in config, not environment)
38
+ anthropic_api_key: Optional[str] = None # Anthropic API key (set in config, not environment)
39
+ openai_api_key: Optional[str] = None # OpenAI API key (set in config, not environment)
38
40
 
39
41
  # LLM Model Configuration
40
42
  llm_anthropic_model: str = "claude-3-5-haiku-20241022" # Claude model to use
41
- llm_openai_model: str = "gpt-4o-mini" # OpenAI model to use
42
- llm_openai_use_responses: bool = False # Use OpenAI Responses API for reasoning models
43
- llm_max_tokens: int = 1000 # Default max tokens
44
- llm_temperature: float = 0.0 # Default temperature (0.0 = deterministic)
43
+ llm_openai_model: str = "gpt-4o-mini" # OpenAI model to use
44
+ llm_openai_use_responses: bool = False # Use OpenAI Responses API for reasoning models
45
+ llm_max_tokens: int = 1000 # Default max tokens
46
+ llm_temperature: float = 0.0 # Default temperature (0.0 = deterministic)
45
47
 
46
48
  @classmethod
47
49
  def load(cls, config_path: Optional[Path] = None) -> "ReAlignConfig":
@@ -49,12 +51,13 @@ class ReAlignConfig:
49
51
  if config_path is None:
50
52
  # Default to new location: ~/.aline/config.yaml
51
53
  config_path = Path.home() / ".aline" / "config.yaml"
52
-
54
+
53
55
  # Check for legacy config and migrate if needed
54
56
  legacy_path = Path.home() / ".config" / "realign" / "config.yaml"
55
57
  if not config_path.exists() and legacy_path.exists():
56
58
  try:
57
59
  import shutil
60
+
58
61
  config_path.parent.mkdir(parents=True, exist_ok=True)
59
62
  shutil.move(legacy_path, config_path)
60
63
  # Try to remove empty legacy directory
@@ -172,10 +175,11 @@ def generate_user_id() -> str:
172
175
  str: User UUID as a string
173
176
  """
174
177
  import uuid
178
+
175
179
  try:
176
180
  mac = uuid.getnode()
177
181
  # Use MAC address with DNS namespace to generate UUID5
178
- namespace = uuid.UUID('6ba7b810-9dad-11d1-80b4-00c04fd430c8') # DNS namespace
182
+ namespace = uuid.UUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8") # DNS namespace
179
183
  user_uuid = uuid.uuid5(namespace, str(mac))
180
184
  return str(user_uuid)
181
185
  except Exception:
@@ -194,8 +198,9 @@ def generate_random_username() -> str:
194
198
  """
195
199
  import random
196
200
  import string
197
- letters = ''.join(random.choices(string.ascii_lowercase, k=3))
198
- digits = ''.join(random.choices(string.digits, k=3))
201
+
202
+ letters = "".join(random.choices(string.ascii_lowercase, k=3))
203
+ digits = "".join(random.choices(string.digits, k=3))
199
204
  return letters + digits
200
205
 
201
206
 
realign/context.py CHANGED
@@ -245,9 +245,7 @@ def add_context(
245
245
  entry = config.contexts[existing_idx]
246
246
  # Add new sessions/events (merge, deduplicate)
247
247
  if sessions:
248
- entry.context_sessions = list(
249
- set(entry.context_sessions) | set(sessions)
250
- )
248
+ entry.context_sessions = list(set(entry.context_sessions) | set(sessions))
251
249
  if events:
252
250
  entry.context_events = list(set(entry.context_events) | set(events))
253
251
  entry.loaded_at = datetime.utcnow().isoformat() + "Z"
realign/dashboard/app.py CHANGED
@@ -73,7 +73,9 @@ class AlineDashboard(App):
73
73
  """Compose the dashboard layout."""
74
74
  yield AlineHeader()
75
75
  tab_ids = self._tab_ids()
76
- with TabbedContent(initial=tab_ids[0] if tab_ids else "watcher"):
76
+ with TabbedContent(initial=tab_ids[0] if tab_ids else "terminal"):
77
+ with TabPane("Terminal", id="terminal"):
78
+ yield TerminalPanel()
77
79
  with TabPane("Watcher", id="watcher"):
78
80
  yield WatcherPanel()
79
81
  with TabPane("Worker", id="worker"):
@@ -86,12 +88,10 @@ class AlineDashboard(App):
86
88
  yield ConfigPanel()
87
89
  with TabPane("Search", id="search"):
88
90
  yield SearchPanel()
89
- with TabPane("Terminal", id="terminal"):
90
- yield TerminalPanel()
91
91
  yield Footer()
92
92
 
93
93
  def _tab_ids(self) -> list[str]:
94
- return ["watcher", "worker", "sessions", "events", "config", "search", "terminal"]
94
+ return ["terminal", "watcher", "worker", "sessions", "events", "config", "search"]
95
95
 
96
96
  def on_mount(self) -> None:
97
97
  """Apply theme based on system settings and watch for changes."""
@@ -121,7 +121,9 @@ class CreateEventScreen(ModalScreen):
121
121
 
122
122
  self.query_one("#create", Button).disabled = True
123
123
  self.query_one("#cancel", Button).disabled = True
124
- self.app.notify("Generating event (aline watcher event generate)...", title="Create Event", timeout=2)
124
+ self.app.notify(
125
+ "Generating event (aline watcher event generate)...", title="Create Event", timeout=2
126
+ )
125
127
 
126
128
  self._worker = self.run_worker(work, thread=True, exit_on_error=False)
127
129
 
@@ -284,7 +284,9 @@ class EventDetailScreen(ModalScreen):
284
284
  else:
285
285
  extra = result.get("stderr") or ""
286
286
  suffix = f": {extra}" if extra else ""
287
- self.app.notify(f"Share export failed (exit {exit_code}){suffix}", title="Share", timeout=6)
287
+ self.app.notify(
288
+ f"Share export failed (exit {exit_code}){suffix}", title="Share", timeout=6
289
+ )
288
290
 
289
291
  def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
290
292
  if event.data_table.id != "event-sessions-table":
@@ -365,7 +367,7 @@ class EventDetailScreen(ModalScreen):
365
367
  "antigravity": "Antigravity",
366
368
  }
367
369
  source = source_map.get(session_type, session_type)
368
- project = (workspace.split("/")[-1] if workspace else "-")
370
+ project = workspace.split("/")[-1] if workspace else "-"
369
371
 
370
372
  self._sessions.append(
371
373
  {
@@ -374,7 +376,9 @@ class EventDetailScreen(ModalScreen):
374
376
  "source": source,
375
377
  "project": project,
376
378
  "turns": int(turn_count or 0),
377
- "total_turns": int(total_turns or 0) if total_turns is not None else None,
379
+ "total_turns": (
380
+ int(total_turns or 0) if total_turns is not None else None
381
+ ),
378
382
  "title": title,
379
383
  "session_summary": session_summary,
380
384
  "last_activity": last_activity,
@@ -397,7 +401,7 @@ class EventDetailScreen(ModalScreen):
397
401
  "antigravity": "Antigravity",
398
402
  }
399
403
  source = source_map.get(session_type, session_type)
400
- project = (str(workspace).split("/")[-1] if workspace else "-")
404
+ project = str(workspace).split("/")[-1] if workspace else "-"
401
405
 
402
406
  self._sessions.append(
403
407
  {
@@ -467,7 +471,9 @@ class EventDetailScreen(ModalScreen):
467
471
  selected_session_id: Optional[str] = None
468
472
  try:
469
473
  if table.row_count > 0:
470
- selected_session_id = str(table.coordinate_to_cell_key(table.cursor_coordinate)[0].value)
474
+ selected_session_id = str(
475
+ table.coordinate_to_cell_key(table.cursor_coordinate)[0].value
476
+ )
471
477
  except Exception:
472
478
  selected_session_id = None
473
479
  table.clear()
@@ -529,7 +535,9 @@ class EventDetailScreen(ModalScreen):
529
535
  "gemini": "Gemini",
530
536
  "antigravity": "Antigravity",
531
537
  }
532
- source = source_map.get(record_type or "", record_type or session.get("source") or "unknown")
538
+ source = source_map.get(
539
+ record_type or "", record_type or session.get("source") or "unknown"
540
+ )
533
541
 
534
542
  title = record_title or session.get("title") or "(no title)"
535
543
  summary = record_summary or session.get("session_summary") or ""
@@ -162,7 +162,9 @@ class SessionDetailScreen(ModalScreen):
162
162
  session_title = getattr(self._session, "session_title", None) if self._session else None
163
163
  session_type = getattr(self._session, "session_type", None) if self._session else None
164
164
  workspace_path = getattr(self._session, "workspace_path", None) if self._session else None
165
- last_activity_at = getattr(self._session, "last_activity_at", None) if self._session else None
165
+ last_activity_at = (
166
+ getattr(self._session, "last_activity_at", None) if self._session else None
167
+ )
166
168
  started_at = getattr(self._session, "started_at", None) if self._session else None
167
169
 
168
170
  source_map = {
@@ -74,7 +74,9 @@ class ShareImportScreen(ModalScreen):
74
74
  placeholder="Paste share URL (e.g. https://.../share/abc123)",
75
75
  )
76
76
  with Horizontal(classes="row"):
77
- yield Input(id="share-password", placeholder="Password (optional)", password=True)
77
+ yield Input(
78
+ id="share-password", placeholder="Password (optional)", password=True
79
+ )
78
80
  with Horizontal(classes="row"):
79
81
  yield Checkbox("Force re-import (override duplicates)", id="share-force")
80
82
  with Horizontal(id="share-import-actions", classes="row"):
@@ -107,7 +109,10 @@ class ShareImportScreen(ModalScreen):
107
109
  self.query_one("#cancel", Button).disabled = busy
108
110
 
109
111
  def _start_import(self) -> None:
110
- if self._worker is not None and self._worker.state in (WorkerState.PENDING, WorkerState.RUNNING):
112
+ if self._worker is not None and self._worker.state in (
113
+ WorkerState.PENDING,
114
+ WorkerState.RUNNING,
115
+ ):
111
116
  return
112
117
 
113
118
  share_url = self.query_one("#share-url", Input).value.strip()
@@ -181,4 +186,3 @@ class ShareImportScreen(ModalScreen):
181
186
  message = f"{message}: {stderr_text}"
182
187
  status.update(f"[red]{message}[/red]")
183
188
  self.app.notify(message, title="Share Import", severity="error", timeout=6)
184
-
@@ -14,6 +14,7 @@ import shutil
14
14
  import stat
15
15
  import subprocess
16
16
  import sys
17
+ import time
17
18
  import uuid
18
19
  from dataclasses import dataclass
19
20
  from pathlib import Path
@@ -39,6 +40,7 @@ OPT_SESSION_ID = "@aline_session_id"
39
40
  OPT_TRANSCRIPT_PATH = "@aline_transcript_path"
40
41
  OPT_CONTEXT_ID = "@aline_context_id"
41
42
  OPT_ATTENTION = "@aline_attention"
43
+ OPT_CREATED_AT = "@aline_created_at"
42
44
 
43
45
 
44
46
  @dataclass(frozen=True)
@@ -53,6 +55,7 @@ class InnerWindow:
53
55
  transcript_path: str | None = None
54
56
  context_id: str | None = None
55
57
  attention: str | None = None # "permission_request", "stop", or None
58
+ created_at: float | None = None # Unix timestamp when window was created
56
59
 
57
60
 
58
61
  def tmux_available() -> bool:
@@ -93,7 +96,9 @@ def _run_tmux(args: Sequence[str], *, capture: bool = False) -> subprocess.Compl
93
96
  )
94
97
 
95
98
 
96
- def _run_outer_tmux(args: Sequence[str], *, capture: bool = False) -> subprocess.CompletedProcess[str]:
99
+ def _run_outer_tmux(
100
+ args: Sequence[str], *, capture: bool = False
101
+ ) -> subprocess.CompletedProcess[str]:
97
102
  """Run tmux commands against the dedicated outer server socket."""
98
103
  return _run_tmux(["-L", OUTER_SOCKET, *args], capture=capture)
99
104
 
@@ -107,7 +112,11 @@ def _run_inner_tmux(
107
112
  def _python_dashboard_command() -> str:
108
113
  # Use the current interpreter for predictable environments (venv, editable installs).
109
114
  python_cmd = shlex.join(
110
- [sys.executable, "-c", "from realign.dashboard.app import run_dashboard; run_dashboard()"]
115
+ [
116
+ sys.executable,
117
+ "-c",
118
+ "from realign.dashboard.app import run_dashboard; run_dashboard()",
119
+ ]
111
120
  )
112
121
  return f"{MANAGED_ENV}=1 {python_cmd}"
113
122
 
@@ -206,7 +215,16 @@ def _aline_tmux_conf_path() -> Path:
206
215
  def _source_aline_tmux_config(run_fn) -> None: # type: ignore[no-untyped-def]
207
216
  """Best-effort source ~/.aline/tmux/tmux.conf if present."""
208
217
  try:
209
- conf = _aline_tmux_conf_path()
218
+ # Ensure the config exists and is parseable.
219
+ # Users may run `aline dashboard` before `aline init`, or have older auto-generated configs
220
+ # that included unquoted `#` bindings (tmux treats `#` as a comment delimiter).
221
+ try:
222
+ from ..commands.init import _initialize_tmux_config
223
+
224
+ conf = _initialize_tmux_config()
225
+ except Exception:
226
+ conf = _aline_tmux_conf_path()
227
+
210
228
  if conf.exists():
211
229
  run_fn(["source-file", str(conf)])
212
230
  except Exception:
@@ -287,7 +305,11 @@ def _maximize_terminal_window() -> None:
287
305
 
288
306
  if front_app == "Terminal":
289
307
  proc = subprocess.run(
290
- ["osascript", "-e", 'tell application "Terminal" to set zoomed of front window to true'],
308
+ [
309
+ "osascript",
310
+ "-e",
311
+ 'tell application "Terminal" to set zoomed of front window to true',
312
+ ],
291
313
  capture_output=True,
292
314
  text=True,
293
315
  timeout=2,
@@ -374,6 +396,17 @@ def bootstrap_dashboard_into_tmux() -> None:
374
396
  # Enable mouse for the managed session only.
375
397
  _run_outer_tmux(["set-option", "-t", OUTER_SESSION, "mouse", "on"])
376
398
 
399
+ # Disable status bar for cleaner UI (Aline sessions only).
400
+ _run_outer_tmux(["set-option", "-t", OUTER_SESSION, "status", "off"])
401
+
402
+ # Pane border styling - use double lines for wider, more visible borders.
403
+ # This helps users identify the resizable border area more easily and reduces
404
+ # accidental drag-to-resize when trying to select text near the border.
405
+ _run_outer_tmux(["set-option", "-t", OUTER_SESSION, "pane-border-lines", "double"])
406
+ _run_outer_tmux(["set-option", "-t", OUTER_SESSION, "pane-border-style", "fg=brightblack"])
407
+ _run_outer_tmux(["set-option", "-t", OUTER_SESSION, "pane-active-border-style", "fg=blue"])
408
+ _run_outer_tmux(["set-option", "-t", OUTER_SESSION, "pane-border-indicators", "arrows"])
409
+
377
410
  # Ensure dashboard window exists.
378
411
  windows_out = (
379
412
  _run_outer_tmux(
@@ -419,6 +452,18 @@ def ensure_inner_session() -> bool:
419
452
 
420
453
  # Dedicated inner server; safe to enable mouse globally there.
421
454
  _run_inner_tmux(["set-option", "-g", "mouse", "on"])
455
+
456
+ # Disable status bar for cleaner UI.
457
+ _run_inner_tmux(["set-option", "-t", INNER_SESSION, "status", "off"])
458
+
459
+ # Pane border styling - use double lines for wider, more visible borders.
460
+ # This helps users identify the resizable border area more easily and reduces
461
+ # accidental drag-to-resize when trying to select text near the border.
462
+ _run_inner_tmux(["set-option", "-g", "pane-border-lines", "double"])
463
+ _run_inner_tmux(["set-option", "-g", "pane-border-style", "fg=brightblack"])
464
+ _run_inner_tmux(["set-option", "-g", "pane-active-border-style", "fg=blue"])
465
+ _run_inner_tmux(["set-option", "-g", "pane-border-indicators", "arrows"])
466
+
422
467
  _source_aline_tmux_config(_run_inner_tmux)
423
468
  return True
424
469
 
@@ -433,7 +478,13 @@ def ensure_right_pane(width_percent: int = 50) -> bool:
433
478
 
434
479
  panes_out = (
435
480
  _run_tmux(
436
- ["list-panes", "-t", f"{OUTER_SESSION}:{OUTER_WINDOW}", "-F", "#{pane_index}"],
481
+ [
482
+ "list-panes",
483
+ "-t",
484
+ f"{OUTER_SESSION}:{OUTER_WINDOW}",
485
+ "-F",
486
+ "#{pane_index}",
487
+ ],
437
488
  capture=True,
438
489
  ).stdout
439
490
  or ""
@@ -484,6 +535,8 @@ def list_inner_windows() -> list[InnerWindow]:
484
535
  + OPT_CONTEXT_ID
485
536
  + "}\t#{"
486
537
  + OPT_ATTENTION
538
+ + "}\t#{"
539
+ + OPT_CREATED_AT
487
540
  + "}",
488
541
  ],
489
542
  capture=True,
@@ -505,6 +558,13 @@ def list_inner_windows() -> list[InnerWindow]:
505
558
  transcript_path = parts[7] if len(parts) > 7 and parts[7] else None
506
559
  context_id = parts[8] if len(parts) > 8 and parts[8] else None
507
560
  attention = parts[9] if len(parts) > 9 and parts[9] else None
561
+ created_at_str = parts[10] if len(parts) > 10 and parts[10] else None
562
+ created_at: float | None = None
563
+ if created_at_str:
564
+ try:
565
+ created_at = float(created_at_str)
566
+ except ValueError:
567
+ pass
508
568
 
509
569
  if terminal_id:
510
570
  persisted = state.get(terminal_id) or {}
@@ -535,8 +595,11 @@ def list_inner_windows() -> list[InnerWindow]:
535
595
  transcript_path=transcript_path,
536
596
  context_id=context_id,
537
597
  attention=attention,
598
+ created_at=created_at,
538
599
  )
539
600
  )
601
+ # Sort by creation time (newest first). Windows without created_at go to the bottom.
602
+ windows.sort(key=lambda w: w.created_at if w.created_at is not None else 0, reverse=True)
540
603
  return windows
541
604
 
542
605
 
@@ -551,11 +614,6 @@ def set_inner_window_options(window_id: str, options: dict[str, str]) -> bool:
551
614
  return ok
552
615
 
553
616
 
554
- def clear_attention(window_id: str) -> bool:
555
- """Clear attention state on a window."""
556
- return set_inner_window_options(window_id, {OPT_ATTENTION: ""})
557
-
558
-
559
617
  def kill_inner_window(window_id: str) -> bool:
560
618
  if not ensure_inner_session():
561
619
  return False
@@ -576,6 +634,9 @@ def create_inner_window(
576
634
  existing = list_inner_windows()
577
635
  name = _unique_name((w.window_name for w in existing), base_name)
578
636
 
637
+ # Record creation time before creating the window
638
+ created_at = time.time()
639
+
579
640
  proc = _run_inner_tmux(
580
641
  [
581
642
  "new-window",
@@ -598,18 +659,18 @@ def create_inner_window(
598
659
  return None
599
660
  window_id, window_name = (created[0].split("\t", 1) + [""])[:2]
600
661
 
601
- if terminal_id or provider or context_id:
602
- opts: dict[str, str] = {}
603
- if terminal_id:
604
- opts[OPT_TERMINAL_ID] = terminal_id
605
- if provider:
606
- opts[OPT_PROVIDER] = provider
607
- if context_id:
608
- opts[OPT_CONTEXT_ID] = context_id
609
- opts.setdefault(OPT_SESSION_TYPE, "")
610
- opts.setdefault(OPT_SESSION_ID, "")
611
- opts.setdefault(OPT_TRANSCRIPT_PATH, "")
612
- set_inner_window_options(window_id, opts)
662
+ # Always set options including the creation timestamp
663
+ opts: dict[str, str] = {OPT_CREATED_AT: str(created_at)}
664
+ if terminal_id:
665
+ opts[OPT_TERMINAL_ID] = terminal_id
666
+ if provider:
667
+ opts[OPT_PROVIDER] = provider
668
+ if context_id:
669
+ opts[OPT_CONTEXT_ID] = context_id
670
+ opts.setdefault(OPT_SESSION_TYPE, "")
671
+ opts.setdefault(OPT_SESSION_ID, "")
672
+ opts.setdefault(OPT_TRANSCRIPT_PATH, "")
673
+ set_inner_window_options(window_id, opts)
613
674
 
614
675
  _run_inner_tmux(["select-window", "-t", window_id])
615
676
 
@@ -620,6 +681,7 @@ def create_inner_window(
620
681
  terminal_id=terminal_id,
621
682
  provider=provider,
622
683
  context_id=context_id,
684
+ created_at=created_at,
623
685
  )
624
686
 
625
687
 
@@ -629,6 +691,13 @@ def select_inner_window(window_id: str) -> bool:
629
691
  return _run_inner_tmux(["select-window", "-t", window_id]).returncode == 0
630
692
 
631
693
 
694
+ def clear_attention(window_id: str) -> bool:
695
+ """Clear the attention state for a window (e.g., after user acknowledges permission request)."""
696
+ if not ensure_inner_session():
697
+ return False
698
+ return _run_inner_tmux(["set-option", "-w", "-t", window_id, OPT_ATTENTION, ""]).returncode == 0
699
+
700
+
632
701
  def get_active_claude_context_id() -> str | None:
633
702
  """Return the active inner tmux window's Claude ALINE_CONTEXT_ID (if any)."""
634
703
  try: