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.
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,7 +61,15 @@ 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()
69
+ self._diagnostics.event(
70
+ "dashboard_init",
71
+ native_terminal=bool(self._native_terminal_mode),
72
+ )
61
73
  logger.info(f"AlineDashboard initialized (native_terminal={self._native_terminal_mode})")
62
74
 
63
75
  def _detect_native_mode(self) -> bool:
@@ -92,6 +104,11 @@ class AlineDashboard(App):
92
104
  logger.info("on_mount() started")
93
105
  try:
94
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
95
112
 
96
113
  # Start local API server for one-click browser import
97
114
  self._start_local_api_server()
@@ -100,11 +117,129 @@ class AlineDashboard(App):
100
117
  if self._native_terminal_mode:
101
118
  self._setup_native_terminal_layout()
102
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
+
103
124
  logger.info("on_mount() completed successfully")
104
125
  except Exception as e:
105
126
  logger.error(f"on_mount() failed: {e}\n{traceback.format_exc()}")
106
127
  raise
107
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
+
108
243
  def _start_local_api_server(self) -> None:
109
244
  """Start the local HTTP API server for browser-based agent import."""
110
245
  try:
@@ -177,6 +312,10 @@ class AlineDashboard(App):
177
312
 
178
313
  async def action_refresh(self) -> None:
179
314
  """Refresh the current tab."""
315
+ try:
316
+ self._diagnostics.event("action_refresh")
317
+ except Exception:
318
+ pass
180
319
  tabbed_content = self.query_one(TabbedContent)
181
320
  active_tab_id = tabbed_content.active
182
321
 
@@ -191,6 +330,17 @@ class AlineDashboard(App):
191
330
 
192
331
  self.push_screen(HelpScreen())
193
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
+
194
344
  def action_quit_confirm(self) -> None:
195
345
  """Quit only when Ctrl+C is pressed twice quickly."""
196
346
  now = _monotonic()
@@ -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;