aline-ai 0.7.2__py3-none-any.whl → 0.7.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.
realign/config.py CHANGED
@@ -19,7 +19,7 @@ class ReAlignConfig:
19
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
21
  auto_detect_codex: bool = True # Enable Codex session auto-detection
22
- auto_detect_gemini: bool = True # Enable Gemini CLI session auto-detection
22
+ auto_detect_gemini: bool = False # Enable Gemini CLI session auto-detection (disabled by default)
23
23
  mcp_auto_commit: bool = True # Enable watcher auto-commit after each user request completes
24
24
  enable_temp_turn_titles: bool = True # Generate temporary turn titles on user prompt submit
25
25
  share_backend_url: str = (
@@ -36,10 +36,6 @@ class ReAlignConfig:
36
36
  # Local API server port (for one-click browser import)
37
37
  local_api_port: int = 17280
38
38
 
39
- # Terminal auto-close settings
40
- auto_close_stale_terminals: bool = False # Auto-close terminals inactive for 24+ hours
41
- stale_terminal_hours: int = 24 # Hours of inactivity before auto-closing
42
-
43
39
  @classmethod
44
40
  def load(cls, config_path: Optional[Path] = None) -> "ReAlignConfig":
45
41
  """Load configuration from file with environment variable overrides."""
@@ -89,13 +85,11 @@ class ReAlignConfig:
89
85
  "uid": os.getenv("REALIGN_UID"),
90
86
  "max_catchup_sessions": os.getenv("REALIGN_MAX_CATCHUP_SESSIONS"),
91
87
  "local_api_port": os.getenv("ALINE_LOCAL_API_PORT"),
92
- "auto_close_stale_terminals": os.getenv("REALIGN_AUTO_CLOSE_STALE_TERMINALS"),
93
- "stale_terminal_hours": os.getenv("REALIGN_STALE_TERMINAL_HOURS"),
94
88
  }
95
89
 
96
90
  for key, value in env_overrides.items():
97
91
  if value is not None:
98
- if key in ["summary_max_chars", "max_catchup_sessions", "stale_terminal_hours", "local_api_port"]:
92
+ if key in ["summary_max_chars", "max_catchup_sessions", "local_api_port"]:
99
93
  config_dict[key] = int(value)
100
94
  elif key in [
101
95
  "redact_on_match",
@@ -105,7 +99,6 @@ class ReAlignConfig:
105
99
  "auto_detect_gemini",
106
100
  "mcp_auto_commit",
107
101
  "enable_temp_turn_titles",
108
- "auto_close_stale_terminals",
109
102
  ]:
110
103
  config_dict[key] = value.lower() in ("true", "1", "yes")
111
104
  else:
@@ -144,8 +137,6 @@ class ReAlignConfig:
144
137
  "uid": self.uid,
145
138
  "max_catchup_sessions": self.max_catchup_sessions,
146
139
  "local_api_port": self.local_api_port,
147
- "auto_close_stale_terminals": self.auto_close_stale_terminals,
148
- "stale_terminal_hours": self.stale_terminal_hours,
149
140
  }
150
141
 
151
142
  with open(config_path, "w", encoding="utf-8") as f:
@@ -182,6 +173,7 @@ use_LLM: true # Whether to use a cloud LLM to generate summar
182
173
  llm_provider: "auto" # LLM provider: "auto" (try Claude then OpenAI), "claude", or "openai"
183
174
  auto_detect_claude: true # Automatically detect Claude Code session directory (~/.claude/projects/)
184
175
  auto_detect_codex: true # Automatically detect Codex session files (~/.codex/sessions/)
176
+ auto_detect_gemini: false # Gemini auto-detect is disabled by default
185
177
  mcp_auto_commit: true # Enable watcher to auto-commit after each user request completes
186
178
  enable_temp_turn_titles: true # Generate temporary turn titles on user prompt submit
187
179
  share_backend_url: "https://realign-server.vercel.app" # Backend URL for interactive share export
realign/dashboard/app.py CHANGED
@@ -1,14 +1,17 @@
1
1
  """Aline Dashboard - Main Application."""
2
2
 
3
+ import asyncio
3
4
  import os
4
5
  import time
5
6
  import traceback
6
7
 
7
8
  from textual.app import App, ComposeResult
8
9
  from textual.binding import Binding
10
+ from textual.worker import Worker, WorkerState
9
11
  from textual.widgets import Footer, TabbedContent, TabPane
10
12
 
11
13
  from ..logging_config import setup_logger
14
+ from .diagnostics import DashboardDiagnostics
12
15
  from .widgets import (
13
16
  AlineHeader,
14
17
  ConfigPanel,
@@ -40,6 +43,7 @@ class AlineDashboard(App):
40
43
  Binding("?", "help", "Help"),
41
44
  Binding("tab", "next_tab", "Next Tab", priority=True, show=False),
42
45
  Binding("shift+tab", "prev_tab", "Prev Tab", priority=True, show=False),
46
+ Binding("ctrl+d", "dump_diagnostics", "Dump Diagnostics", show=False),
43
47
  Binding("ctrl+c", "quit_confirm", "Quit", priority=True),
44
48
  ]
45
49
 
@@ -57,10 +61,16 @@ class AlineDashboard(App):
57
61
  self.use_native_terminal = use_native_terminal
58
62
  self._native_terminal_mode = self._detect_native_mode()
59
63
  self._local_api_server = None
64
+ self._diagnostics = DashboardDiagnostics.start()
65
+ self._tmux_width_timer = None
66
+ self._diagnostics.install_global_exception_hooks()
67
+ self._watchdog_timer = None
60
68
  self._apply_saved_theme()
61
- logger.info(
62
- f"AlineDashboard initialized (native_terminal={self._native_terminal_mode})"
69
+ self._diagnostics.event(
70
+ "dashboard_init",
71
+ native_terminal=bool(self._native_terminal_mode),
63
72
  )
73
+ logger.info(f"AlineDashboard initialized (native_terminal={self._native_terminal_mode})")
64
74
 
65
75
  def _detect_native_mode(self) -> bool:
66
76
  """Detect if native terminal mode should be used."""
@@ -94,6 +104,11 @@ class AlineDashboard(App):
94
104
  logger.info("on_mount() started")
95
105
  try:
96
106
  self._quit_confirm_deadline: float | None = None
107
+ try:
108
+ loop = asyncio.get_running_loop()
109
+ self._diagnostics.install_asyncio_exception_handler(loop)
110
+ except Exception:
111
+ pass
97
112
 
98
113
  # Start local API server for one-click browser import
99
114
  self._start_local_api_server()
@@ -102,11 +117,129 @@ class AlineDashboard(App):
102
117
  if self._native_terminal_mode:
103
118
  self._setup_native_terminal_layout()
104
119
 
120
+ # Watchdog: capture snapshots for intermittent blank/stuck UI issues.
121
+ if self._watchdog_timer is None:
122
+ self._watchdog_timer = self.set_interval(5.0, self._watchdog_check)
123
+
105
124
  logger.info("on_mount() completed successfully")
106
125
  except Exception as e:
107
126
  logger.error(f"on_mount() failed: {e}\n{traceback.format_exc()}")
108
127
  raise
109
128
 
129
+ def on_resize(self) -> None:
130
+ """Keep the tmux outer layout stable when the terminal is resized."""
131
+ if self._native_terminal_mode:
132
+ return
133
+
134
+ # Debounce: terminal resize can generate many events; keep the UI responsive.
135
+ try:
136
+ if self._tmux_width_timer is not None:
137
+ try:
138
+ self._tmux_width_timer.stop()
139
+ except Exception:
140
+ pass
141
+ self._tmux_width_timer = self.set_timer(0.05, self._enforce_tmux_dashboard_width)
142
+ except Exception:
143
+ return
144
+
145
+ def _enforce_tmux_dashboard_width(self) -> None:
146
+ try:
147
+ from . import tmux_manager
148
+
149
+ tmux_manager.enforce_outer_dashboard_pane_width()
150
+ except Exception:
151
+ return
152
+
153
+ def on_unmount(self) -> None:
154
+ try:
155
+ self._diagnostics.event("dashboard_unmount")
156
+ except Exception:
157
+ pass
158
+
159
+ def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
160
+ if event.state != WorkerState.ERROR:
161
+ return
162
+ try:
163
+ worker = event.worker
164
+ err = getattr(worker, "error", None)
165
+ if isinstance(err, BaseException):
166
+ self._diagnostics.exception(
167
+ "worker_error",
168
+ err,
169
+ worker_name=str(getattr(worker, "name", "") or ""),
170
+ worker_group=str(getattr(worker, "group", "") or ""),
171
+ worker_state=str(event.state),
172
+ )
173
+ else:
174
+ self._diagnostics.event(
175
+ "worker_error",
176
+ error=str(err),
177
+ worker_name=str(getattr(worker, "name", "") or ""),
178
+ worker_group=str(getattr(worker, "group", "") or ""),
179
+ worker_state=str(event.state),
180
+ )
181
+ except Exception:
182
+ return
183
+
184
+ def _watchdog_check(self) -> None:
185
+ """Lightweight watchdog to detect UI blanking and missing panes."""
186
+ try:
187
+ panel = self.query_one(AgentsPanel)
188
+ state = panel.diagnostics_state()
189
+ agents_count = int(state.get("agents_count", 0) or 0)
190
+ children = int(state.get("agents_list_children", 1) or 0)
191
+ if agents_count > 0 and children == 0:
192
+ self._diagnostics.snapshot(reason="agents_panel_blank", app=self, agents_panel=state)
193
+ panel.force_render(reason="watchdog_blank")
194
+ except Exception:
195
+ pass
196
+
197
+ # Only relevant for tmux mode.
198
+ if self._native_terminal_mode:
199
+ return
200
+ try:
201
+ from . import tmux_manager
202
+
203
+ panes = tmux_manager.list_outer_panes(timeout_s=0.2)
204
+ if panes and len(panes) < 2:
205
+ try:
206
+ tmux_state = tmux_manager.collect_tmux_debug_state()
207
+ except Exception:
208
+ tmux_state = {}
209
+ self._diagnostics.snapshot(
210
+ reason="tmux_outer_missing_right_pane",
211
+ app=self,
212
+ tmux_state=tmux_state,
213
+ )
214
+ try:
215
+ tmux_manager.ensure_right_pane_ready()
216
+ except Exception:
217
+ pass
218
+ else:
219
+ pane1_cmd = ""
220
+ for ln in panes:
221
+ parts = ln.split("\t")
222
+ if parts and parts[0] == "1":
223
+ pane1_cmd = parts[2] if len(parts) > 2 else ""
224
+ break
225
+ if pane1_cmd and pane1_cmd != "tmux":
226
+ try:
227
+ tmux_state = tmux_manager.collect_tmux_debug_state()
228
+ except Exception:
229
+ tmux_state = {}
230
+ self._diagnostics.snapshot(
231
+ reason="tmux_right_pane_not_attached",
232
+ app=self,
233
+ pane1_current_command=pane1_cmd,
234
+ tmux_state=tmux_state,
235
+ )
236
+ try:
237
+ tmux_manager.ensure_right_pane_ready()
238
+ except Exception:
239
+ pass
240
+ except Exception:
241
+ pass
242
+
110
243
  def _start_local_api_server(self) -> None:
111
244
  """Start the local HTTP API server for browser-based agent import."""
112
245
  try:
@@ -179,6 +312,10 @@ class AlineDashboard(App):
179
312
 
180
313
  async def action_refresh(self) -> None:
181
314
  """Refresh the current tab."""
315
+ try:
316
+ self._diagnostics.event("action_refresh")
317
+ except Exception:
318
+ pass
182
319
  tabbed_content = self.query_one(TabbedContent)
183
320
  active_tab_id = tabbed_content.active
184
321
 
@@ -193,6 +330,17 @@ class AlineDashboard(App):
193
330
 
194
331
  self.push_screen(HelpScreen())
195
332
 
333
+ def action_dump_diagnostics(self) -> None:
334
+ try:
335
+ self._diagnostics.snapshot(reason="manual_dump", app=self)
336
+ path = self._diagnostics.path
337
+ if path:
338
+ self.notify(f"Diagnostics written: {path}", title="Diagnostics", timeout=4)
339
+ else:
340
+ self.notify("Diagnostics written to stderr (no log dir)", title="Diagnostics", timeout=4)
341
+ except Exception as e:
342
+ self.notify(f"Diagnostics failed: {e}", title="Diagnostics", severity="error", timeout=4)
343
+
196
344
  def action_quit_confirm(self) -> None:
197
345
  """Quit only when Ctrl+C is pressed twice quickly."""
198
346
  now = _monotonic()
@@ -204,6 +352,7 @@ class AlineDashboard(App):
204
352
  self._quit_confirm_deadline = now + self._quit_confirm_window_s
205
353
  self.notify("Press Ctrl+C again to quit", title="Quit", timeout=2)
206
354
 
355
+
207
356
  def run_dashboard(use_native_terminal: bool | None = None) -> None:
208
357
  """Run the Aline Dashboard.
209
358
 
@@ -0,0 +1,274 @@
1
+ """Dashboard diagnostics and high-signal logging.
2
+
3
+ This module is intentionally best-effort: diagnostics should never crash the dashboard.
4
+ It provides a JSONL log (one JSON object per line) with correlation IDs and state
5
+ snapshots for debugging intermittent UI issues (blank panes, stuck refreshes, etc.).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ import os
14
+ import platform
15
+ import sys
16
+ import threading
17
+ import time
18
+ import traceback
19
+ import uuid
20
+ from dataclasses import dataclass
21
+ from datetime import datetime
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from ..logging_config import get_log_directory
26
+
27
+
28
+ def _safe_json(obj: Any) -> str:
29
+ return json.dumps(obj, ensure_ascii=False, default=str, separators=(",", ":"))
30
+
31
+
32
+ def _now_iso() -> str:
33
+ return datetime.now().isoformat(timespec="seconds")
34
+
35
+
36
+ def _selected_env() -> dict[str, str]:
37
+ allow_prefixes = ("ALINE_", "REALIGN_")
38
+ allow_keys = {
39
+ "TERM",
40
+ "TERM_PROGRAM",
41
+ "TERM_PROGRAM_VERSION",
42
+ "TMUX",
43
+ "SHELL",
44
+ "LANG",
45
+ "LC_ALL",
46
+ }
47
+ out: dict[str, str] = {}
48
+ for k, v in os.environ.items():
49
+ if any(k.startswith(p) for p in allow_prefixes) or k in allow_keys:
50
+ out[k] = str(v)
51
+ return out
52
+
53
+
54
+ _DIAG_SINGLETON: "DashboardDiagnostics | None" = None
55
+
56
+
57
+ def reset_dashboard_diagnostics_for_tests() -> None:
58
+ """Reset the diagnostics singleton/handlers (intended for pytest)."""
59
+ global _DIAG_SINGLETON
60
+ _DIAG_SINGLETON = None
61
+ logger = logging.getLogger("realign.dashboard.diagnostics")
62
+ for h in list(logger.handlers):
63
+ try:
64
+ logger.removeHandler(h)
65
+ except Exception:
66
+ pass
67
+ try:
68
+ h.close()
69
+ except Exception:
70
+ pass
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class DashboardDiagnostics:
75
+ session_id: str
76
+ path: Path | None
77
+ logger: logging.Logger
78
+
79
+ @classmethod
80
+ def start(cls) -> "DashboardDiagnostics":
81
+ """Create a per-run diagnostics logger (never raises)."""
82
+ global _DIAG_SINGLETON
83
+ if _DIAG_SINGLETON is not None:
84
+ return _DIAG_SINGLETON
85
+
86
+ session_id = uuid.uuid4().hex[:12]
87
+
88
+ logger = logging.getLogger("realign.dashboard.diagnostics")
89
+ logger.setLevel(logging.DEBUG)
90
+ logger.propagate = False
91
+
92
+ # Avoid duplicating handlers across hot reloads / repeated imports.
93
+ if logger.handlers:
94
+ diag = cls(session_id=session_id, path=None, logger=logger)
95
+ _DIAG_SINGLETON = diag
96
+ return diag
97
+
98
+ path: Path | None = None
99
+ handler: logging.Handler | None = None
100
+
101
+ try:
102
+ root = get_log_directory()
103
+ diag_dir = root / "dashboard_diagnostics"
104
+ diag_dir.mkdir(parents=True, exist_ok=True)
105
+ stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
106
+ path = diag_dir / f"dashboard_{stamp}_pid{os.getpid()}_{session_id}.jsonl"
107
+ handler = logging.FileHandler(path, encoding="utf-8")
108
+ except Exception:
109
+ # Fall back to stderr; still better than losing all diagnostics.
110
+ handler = logging.StreamHandler()
111
+ path = None
112
+
113
+ handler.setLevel(logging.DEBUG)
114
+ handler.setFormatter(logging.Formatter(fmt="%(message)s"))
115
+ logger.addHandler(handler)
116
+
117
+ diag = cls(session_id=session_id, path=path, logger=logger)
118
+ _DIAG_SINGLETON = diag
119
+ diag.event(
120
+ "dashboard_diagnostics_started",
121
+ pid=os.getpid(),
122
+ python=sys.version.split()[0],
123
+ platform=platform.platform(),
124
+ env=_selected_env(),
125
+ )
126
+ return diag
127
+
128
+ def event(self, name: str, **fields: Any) -> None:
129
+ payload = {
130
+ "ts": _now_iso(),
131
+ "t_monotonic": round(time.monotonic(), 6),
132
+ "pid": os.getpid(),
133
+ "thread": threading.current_thread().name,
134
+ "session_id": self.session_id,
135
+ "event": name,
136
+ **fields,
137
+ }
138
+ try:
139
+ self.logger.info(_safe_json(payload))
140
+ except Exception:
141
+ # Diagnostics must never crash the app.
142
+ return
143
+
144
+ def exception(self, name: str, exc: BaseException, **fields: Any) -> None:
145
+ try:
146
+ tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
147
+ except Exception:
148
+ tb = ""
149
+ self.event(
150
+ name,
151
+ error_type=type(exc).__name__,
152
+ error=str(exc),
153
+ traceback=tb,
154
+ **fields,
155
+ )
156
+
157
+ def snapshot(self, *, reason: str, app: Any | None = None, **fields: Any) -> None: # noqa: ANN401
158
+ """Capture a lightweight state snapshot (best-effort)."""
159
+ snap: dict[str, Any] = {
160
+ "reason": reason,
161
+ "fields": fields,
162
+ }
163
+
164
+ if app is not None:
165
+ snap["app"] = _snapshot_textual_app(app)
166
+
167
+ try:
168
+ from . import tmux_manager
169
+
170
+ snap["tmux"] = tmux_manager.collect_tmux_debug_state()
171
+ except Exception:
172
+ pass
173
+
174
+ self.event("dashboard_snapshot", **snap)
175
+
176
+ def install_global_exception_hooks(self) -> None:
177
+ """Install sys/threading exception hooks (best-effort)."""
178
+ try:
179
+ orig_sys_hook = sys.excepthook
180
+
181
+ def sys_hook(exc_type, exc, tb): # type: ignore[no-untyped-def]
182
+ try:
183
+ self.exception(
184
+ "unhandled_exception",
185
+ exc if isinstance(exc, BaseException) else Exception(str(exc)),
186
+ where="sys.excepthook",
187
+ )
188
+ except Exception:
189
+ pass
190
+ try:
191
+ orig_sys_hook(exc_type, exc, tb)
192
+ except Exception:
193
+ pass
194
+
195
+ sys.excepthook = sys_hook
196
+ except Exception:
197
+ pass
198
+
199
+ try:
200
+ orig_thread_hook = threading.excepthook
201
+
202
+ def thread_hook(args): # type: ignore[no-untyped-def]
203
+ try:
204
+ self.exception(
205
+ "unhandled_exception",
206
+ args.exc_value,
207
+ where="threading.excepthook",
208
+ thread=str(getattr(args, "thread", None)),
209
+ )
210
+ except Exception:
211
+ pass
212
+ try:
213
+ orig_thread_hook(args)
214
+ except Exception:
215
+ pass
216
+
217
+ threading.excepthook = thread_hook
218
+ except Exception:
219
+ pass
220
+
221
+ def install_asyncio_exception_handler(self, loop: asyncio.AbstractEventLoop) -> None:
222
+ """Install an asyncio loop exception handler (best-effort)."""
223
+
224
+ def handler(loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
225
+ exc = context.get("exception")
226
+ if isinstance(exc, BaseException):
227
+ self.exception(
228
+ "asyncio_exception",
229
+ exc,
230
+ message=str(context.get("message") or ""),
231
+ context={k: v for k, v in context.items() if k not in {"exception"}},
232
+ )
233
+ return
234
+ self.event(
235
+ "asyncio_exception",
236
+ message=str(context.get("message") or ""),
237
+ context={k: v for k, v in context.items()},
238
+ )
239
+
240
+ try:
241
+ loop.set_exception_handler(handler)
242
+ except Exception:
243
+ return
244
+
245
+
246
+ def _snapshot_textual_app(app: Any) -> dict[str, Any]: # noqa: ANN401
247
+ snap: dict[str, Any] = {}
248
+
249
+ try:
250
+ snap["title"] = str(getattr(app, "TITLE", "") or "")
251
+ snap["screen"] = type(getattr(app, "screen", None)).__name__
252
+ snap["dark"] = bool(getattr(app, "dark", False))
253
+ snap["theme"] = str(getattr(app, "theme", "") or "")
254
+ except Exception:
255
+ pass
256
+
257
+ # Best-effort widget checks for known failure modes.
258
+ try:
259
+ from textual.widgets import TabbedContent # imported lazily
260
+
261
+ tabbed = app.query_one(TabbedContent)
262
+ snap["active_tab"] = str(getattr(tabbed, "active", "") or "")
263
+ except Exception:
264
+ pass
265
+
266
+ try:
267
+ from textual.containers import Vertical
268
+
269
+ agents_list = app.query_one("#agents-list", Vertical)
270
+ snap["agents_list_children"] = int(len(getattr(agents_list, "children", []) or []))
271
+ except Exception:
272
+ pass
273
+
274
+ return snap
@@ -99,7 +99,8 @@ class CreateAgentScreen(ModalScreen[Optional[tuple[str, str, bool, bool]]]):
99
99
  }
100
100
 
101
101
  CreateAgentScreen #create-agent-root {
102
- width: 60;
102
+ width: 100%;
103
+ max-width: 60;
103
104
  height: auto;
104
105
  max-height: 80%;
105
106
  padding: 1 2;