aline-ai 0.6.5__py3-none-any.whl → 0.6.7__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 (42) hide show
  1. {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/METADATA +1 -1
  2. {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/RECORD +41 -34
  3. realign/__init__.py +1 -1
  4. realign/agent_names.py +79 -0
  5. realign/claude_hooks/stop_hook.py +3 -0
  6. realign/claude_hooks/terminal_state.py +43 -1
  7. realign/claude_hooks/user_prompt_submit_hook.py +3 -0
  8. realign/cli.py +62 -0
  9. realign/codex_detector.py +18 -3
  10. realign/codex_home.py +65 -16
  11. realign/codex_terminal_linker.py +18 -7
  12. realign/commands/agent.py +109 -0
  13. realign/commands/doctor.py +74 -1
  14. realign/commands/export_shares.py +448 -0
  15. realign/commands/import_shares.py +203 -1
  16. realign/commands/search.py +58 -29
  17. realign/commands/sync_agent.py +347 -0
  18. realign/dashboard/app.py +9 -9
  19. realign/dashboard/clipboard.py +54 -0
  20. realign/dashboard/screens/__init__.py +4 -0
  21. realign/dashboard/screens/agent_detail.py +333 -0
  22. realign/dashboard/screens/create_agent_info.py +244 -0
  23. realign/dashboard/screens/event_detail.py +6 -27
  24. realign/dashboard/styles/dashboard.tcss +22 -28
  25. realign/dashboard/tmux_manager.py +36 -10
  26. realign/dashboard/widgets/__init__.py +2 -2
  27. realign/dashboard/widgets/agents_panel.py +1248 -0
  28. realign/dashboard/widgets/events_table.py +4 -27
  29. realign/dashboard/widgets/sessions_table.py +4 -27
  30. realign/db/base.py +69 -0
  31. realign/db/locks.py +4 -0
  32. realign/db/schema.py +111 -2
  33. realign/db/sqlite_db.py +360 -2
  34. realign/events/agent_summarizer.py +157 -0
  35. realign/events/session_summarizer.py +25 -0
  36. realign/watcher_core.py +193 -5
  37. realign/worker_core.py +59 -1
  38. realign/dashboard/widgets/terminal_panel.py +0 -1653
  39. {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/WHEEL +0 -0
  40. {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/entry_points.txt +0 -0
  41. {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/licenses/LICENSE +0 -0
  42. {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,333 @@
1
+ """Agent detail modal for the dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Optional
7
+
8
+ from rich.markup import escape
9
+ from textual.app import ComposeResult
10
+ from textual.binding import Binding
11
+ from textual.containers import Container, Vertical
12
+ from textual.screen import ModalScreen
13
+ from textual.widgets import DataTable, Static
14
+
15
+ from ..widgets.openable_table import OpenableDataTable
16
+
17
+
18
+ def _format_dt(dt: object) -> str:
19
+ if isinstance(dt, datetime):
20
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
21
+ if dt is None:
22
+ return "-"
23
+ return str(dt)
24
+
25
+
26
+ def _format_relative_time(dt: datetime) -> str:
27
+ now = datetime.now()
28
+ diff = now - dt
29
+ seconds = diff.total_seconds()
30
+
31
+ if seconds < 60:
32
+ return "just now"
33
+ if seconds < 3600:
34
+ mins = int(seconds / 60)
35
+ return f"{mins}m ago"
36
+ if seconds < 86400:
37
+ hours = int(seconds / 3600)
38
+ return f"{hours}h ago"
39
+ days = int(seconds / 86400)
40
+ return f"{days}d ago"
41
+
42
+
43
+ def _format_iso_relative(dt: Optional[datetime]) -> str:
44
+ if not dt:
45
+ return "-"
46
+ try:
47
+ return _format_relative_time(dt)
48
+ except Exception:
49
+ return _format_dt(dt)
50
+
51
+
52
+ def _shorten_id(val: str | None) -> str:
53
+ if not val:
54
+ return "-"
55
+ if len(val) <= 20:
56
+ return val
57
+ return f"{val[:8]}...{val[-8:]}"
58
+
59
+
60
+ class AgentDetailScreen(ModalScreen):
61
+ """Modal that shows agent details and its sessions."""
62
+
63
+ BINDINGS = [
64
+ Binding("escape", "close", "Close", show=False),
65
+ ]
66
+
67
+ DEFAULT_CSS = """
68
+ AgentDetailScreen {
69
+ align: center middle;
70
+ }
71
+
72
+ AgentDetailScreen #agent-detail-root {
73
+ width: 95%;
74
+ height: 95%;
75
+ padding: 1;
76
+ background: $background;
77
+ border: none;
78
+ }
79
+
80
+ AgentDetailScreen #agent-meta {
81
+ height: auto;
82
+ margin-bottom: 1;
83
+ }
84
+
85
+ AgentDetailScreen #agent-detail-body {
86
+ height: 1fr;
87
+ }
88
+
89
+ AgentDetailScreen #agent-description {
90
+ height: auto;
91
+ margin-bottom: 1;
92
+ }
93
+
94
+ AgentDetailScreen #agent-sessions-table {
95
+ width: 1fr;
96
+ height: 1fr;
97
+ }
98
+
99
+ AgentDetailScreen #agent-session-preview {
100
+ width: 1fr;
101
+ height: auto;
102
+ margin-top: 1;
103
+ }
104
+
105
+ AgentDetailScreen #agent-hint {
106
+ height: 1;
107
+ margin-top: 1;
108
+ color: $text-muted;
109
+ text-align: right;
110
+ }
111
+ """
112
+
113
+ def __init__(self, agent_id: str) -> None:
114
+ super().__init__()
115
+ self.agent_id = agent_id
116
+ self._load_error: Optional[str] = None
117
+ self._agent_info = None
118
+ self._sessions: list[dict] = []
119
+ self._session_record_cache: dict[str, object] = {}
120
+ self._initialized: bool = False
121
+
122
+ def compose(self) -> ComposeResult:
123
+ with Container(id="agent-detail-root"):
124
+ yield Static(id="agent-meta")
125
+ with Vertical(id="agent-detail-body"):
126
+ yield Static(id="agent-description")
127
+ sessions_table = OpenableDataTable(id="agent-sessions-table")
128
+ sessions_table.add_columns(
129
+ "#",
130
+ "Session ID",
131
+ "Source",
132
+ "Project",
133
+ "Turns",
134
+ "Title",
135
+ "Last Activity",
136
+ )
137
+ sessions_table.cursor_type = "row"
138
+ yield sessions_table
139
+ yield Static(id="agent-session-preview")
140
+ yield Static(
141
+ "Click: select Enter/dblclick: open Esc: close",
142
+ id="agent-hint",
143
+ )
144
+
145
+ def on_show(self) -> None:
146
+ self.call_later(self._ensure_initialized)
147
+
148
+ def _ensure_initialized(self) -> None:
149
+ if self._initialized:
150
+ return
151
+ self._initialized = True
152
+
153
+ sessions_table = self.query_one("#agent-sessions-table", DataTable)
154
+ self._load_data()
155
+ self._update_display()
156
+
157
+ if sessions_table.row_count > 0:
158
+ sessions_table.focus()
159
+
160
+ def action_close(self) -> None:
161
+ self.app.pop_screen()
162
+
163
+ def on_openable_data_table_row_activated(
164
+ self, event: OpenableDataTable.RowActivated
165
+ ) -> None:
166
+ if event.data_table.id != "agent-sessions-table":
167
+ return
168
+
169
+ session_id = str(event.row_key.value)
170
+ if not session_id:
171
+ return
172
+
173
+ from .session_detail import SessionDetailScreen
174
+
175
+ self.app.push_screen(SessionDetailScreen(session_id))
176
+
177
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
178
+ if event.data_table.id != "agent-sessions-table":
179
+ return
180
+ self._update_session_preview(str(event.row_key.value))
181
+
182
+ def _load_data(self) -> None:
183
+ try:
184
+ from ...db import get_database
185
+
186
+ db = get_database()
187
+ self._agent_info = db.get_agent_info(self.agent_id)
188
+ sessions = db.get_sessions_by_agent_id(self.agent_id, limit=1000)
189
+
190
+ source_map = {
191
+ "claude": "Claude",
192
+ "codex": "Codex",
193
+ "gemini": "Gemini",
194
+ }
195
+
196
+ self._sessions = []
197
+ for s in sessions:
198
+ session_id = str(s.id)
199
+ session_type = getattr(s, "session_type", None) or "unknown"
200
+ workspace = getattr(s, "workspace_path", None)
201
+ title = getattr(s, "session_title", None) or "(no title)"
202
+ last_activity = getattr(s, "last_activity_at", None)
203
+ turns = int(getattr(s, "total_turns", 0) or 0)
204
+
205
+ project = str(workspace).split("/")[-1] if workspace else "-"
206
+ source = source_map.get(session_type, session_type)
207
+
208
+ self._sessions.append(
209
+ {
210
+ "id": session_id,
211
+ "short_id": _shorten_id(session_id),
212
+ "source": source,
213
+ "project": project,
214
+ "turns": turns,
215
+ "title": title,
216
+ "last_activity": last_activity,
217
+ }
218
+ )
219
+
220
+ self._sessions.sort(
221
+ key=lambda item: item.get("last_activity") or datetime.min, reverse=True
222
+ )
223
+ self._load_error = None
224
+ except Exception as e:
225
+ self._agent_info = None
226
+ self._sessions = []
227
+ self._session_record_cache = {}
228
+ self._load_error = str(e)
229
+
230
+ def _update_display(self) -> None:
231
+ meta = self.query_one("#agent-meta", Static)
232
+ description = self.query_one("#agent-description", Static)
233
+ preview = self.query_one("#agent-session-preview", Static)
234
+
235
+ if self._load_error:
236
+ meta.update(f"[red]Failed to load agent {self.agent_id}:[/red] {self._load_error}")
237
+ description.update("")
238
+ preview.update("")
239
+ return
240
+
241
+ name = getattr(self._agent_info, "name", None) if self._agent_info else None
242
+ desc = getattr(self._agent_info, "description", None) if self._agent_info else None
243
+ created_at = getattr(self._agent_info, "created_at", None) if self._agent_info else None
244
+ updated_at = getattr(self._agent_info, "updated_at", None) if self._agent_info else None
245
+
246
+ meta_lines = [
247
+ f"[bold]Agent[/bold] {self.agent_id}",
248
+ f"[dim]Name:[/dim] {escape(name) if name else '(no name)'}",
249
+ f"[dim]Created:[/dim] {_format_dt(created_at)} [dim]Updated:[/dim] {_format_dt(updated_at)}",
250
+ f"[dim]Sessions:[/dim] {len(self._sessions)}",
251
+ ]
252
+
253
+ meta.update("\n".join(meta_lines))
254
+ description.update(desc or "(no description)")
255
+ preview.update("")
256
+
257
+ table = self.query_one("#agent-sessions-table", DataTable)
258
+ selected_session_id: Optional[str] = None
259
+ try:
260
+ if table.row_count > 0:
261
+ selected_session_id = str(
262
+ table.coordinate_to_cell_key(table.cursor_coordinate)[0].value
263
+ )
264
+ except Exception:
265
+ selected_session_id = None
266
+ table.clear()
267
+
268
+ for idx, s in enumerate(self._sessions, 1):
269
+ title_cell = s["title"]
270
+ if len(title_cell) > 40:
271
+ title_cell = title_cell[:40] + "..."
272
+
273
+ last_activity_str = _format_iso_relative(s["last_activity"])
274
+
275
+ table.add_row(
276
+ str(idx),
277
+ s["short_id"],
278
+ s["source"],
279
+ s["project"],
280
+ str(s["turns"]),
281
+ title_cell,
282
+ last_activity_str,
283
+ key=s["id"],
284
+ )
285
+
286
+ if table.row_count > 0:
287
+ if selected_session_id:
288
+ try:
289
+ table.cursor_coordinate = (table.get_row_index(selected_session_id), 0)
290
+ except Exception:
291
+ table.cursor_coordinate = (0, 0)
292
+ else:
293
+ table.cursor_coordinate = (0, 0)
294
+
295
+ row_key = table.coordinate_to_cell_key(table.cursor_coordinate)[0]
296
+ self._update_session_preview(str(row_key.value))
297
+
298
+ def _get_session_record(self, session_id: str) -> Optional[object]:
299
+ if session_id in self._session_record_cache:
300
+ return self._session_record_cache[session_id]
301
+ try:
302
+ from ...db import get_database
303
+
304
+ db = get_database()
305
+ record = db.get_session_by_id(session_id)
306
+ self._session_record_cache[session_id] = record
307
+ return record
308
+ except Exception:
309
+ self._session_record_cache[session_id] = None
310
+ return None
311
+
312
+ def _update_session_preview(self, session_id: str) -> None:
313
+ preview = self.query_one("#agent-session-preview", Static)
314
+ if not session_id:
315
+ preview.update("")
316
+ return
317
+
318
+ record = self._get_session_record(session_id)
319
+ if not record:
320
+ preview.update("[dim]No session details available.[/dim]")
321
+ return
322
+
323
+ title = getattr(record, "session_title", None) or "(no title)"
324
+ summary = getattr(record, "session_summary", None) or "(no summary)"
325
+
326
+ preview.update(
327
+ "\n".join(
328
+ [
329
+ f"[bold]Title:[/bold] {escape(title)}",
330
+ f"[bold]Summary:[/bold] {escape(summary)}",
331
+ ]
332
+ )
333
+ )
@@ -0,0 +1,244 @@
1
+ """Create agent info modal for the dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import Optional
7
+
8
+ from textual.app import ComposeResult
9
+ from textual.binding import Binding
10
+ from textual.containers import Container, Horizontal, Vertical
11
+ from textual.screen import ModalScreen
12
+ from textual.widgets import Button, Input, Label, Static
13
+ from textual.worker import Worker, WorkerState
14
+
15
+ from ...logging_config import setup_logger
16
+
17
+ logger = setup_logger("realign.dashboard.screens.create_agent_info", "dashboard.log")
18
+
19
+
20
+ class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
21
+ """Modal to create a new agent profile or import from a share link.
22
+
23
+ Both options are shown together; the user picks one.
24
+ Returns a dict with agent info on success, None on cancel.
25
+ """
26
+
27
+ BINDINGS = [
28
+ Binding("escape", "close", "Close", show=False),
29
+ ]
30
+
31
+ DEFAULT_CSS = """
32
+ CreateAgentInfoScreen {
33
+ align: center middle;
34
+ }
35
+
36
+ CreateAgentInfoScreen #create-agent-info-root {
37
+ width: 65;
38
+ height: auto;
39
+ max-height: 80%;
40
+ padding: 1 2;
41
+ background: $background;
42
+ border: solid $accent;
43
+ }
44
+
45
+ CreateAgentInfoScreen #create-agent-info-title {
46
+ height: auto;
47
+ margin-bottom: 1;
48
+ text-style: bold;
49
+ }
50
+
51
+ CreateAgentInfoScreen .section-label {
52
+ height: auto;
53
+ margin-top: 1;
54
+ margin-bottom: 0;
55
+ color: $text-muted;
56
+ }
57
+
58
+ CreateAgentInfoScreen Input {
59
+ margin-top: 0;
60
+ border: none;
61
+ }
62
+
63
+ CreateAgentInfoScreen #or-separator {
64
+ height: auto;
65
+ margin-top: 1;
66
+ margin-bottom: 0;
67
+ text-align: center;
68
+ color: $text-muted;
69
+ }
70
+
71
+ CreateAgentInfoScreen #import-status {
72
+ height: auto;
73
+ margin-top: 1;
74
+ color: $text-muted;
75
+ }
76
+
77
+ CreateAgentInfoScreen #create-buttons {
78
+ height: auto;
79
+ margin-top: 1;
80
+ align: right middle;
81
+ }
82
+
83
+ CreateAgentInfoScreen #import-buttons {
84
+ height: auto;
85
+ margin-top: 1;
86
+ align: right middle;
87
+ }
88
+
89
+ CreateAgentInfoScreen #create-buttons Button {
90
+ margin-left: 1;
91
+ }
92
+
93
+ CreateAgentInfoScreen #import-buttons Button {
94
+ margin-left: 1;
95
+ }
96
+ """
97
+
98
+ def __init__(self) -> None:
99
+ super().__init__()
100
+ self._import_worker: Optional[Worker] = None
101
+ from ...agent_names import generate_agent_name
102
+ self._default_name: str = generate_agent_name()
103
+
104
+ def compose(self) -> ComposeResult:
105
+ with Container(id="create-agent-info-root"):
106
+ yield Static("Create Agent Profile", id="create-agent-info-title")
107
+
108
+ # --- Create New section ---
109
+ yield Label("Name", classes="section-label")
110
+ yield Input(placeholder=self._default_name, id="agent-name")
111
+
112
+ with Horizontal(id="create-buttons"):
113
+ yield Button("Cancel", id="cancel")
114
+ yield Button("Create", id="create", variant="primary")
115
+
116
+ # --- Separator ---
117
+ yield Static("-- Or --", id="or-separator")
118
+
119
+ # --- Import from Link section ---
120
+ yield Label("Import from Link", classes="section-label")
121
+ yield Input(placeholder="https://realign-server.vercel.app/share/...", id="share-url")
122
+
123
+ yield Label("Password (optional)", classes="section-label")
124
+ yield Input(placeholder="Leave blank if not password-protected", id="share-password", password=True)
125
+
126
+ yield Static("", id="import-status")
127
+
128
+ with Horizontal(id="import-buttons"):
129
+ yield Button("Import", id="import", variant="primary")
130
+
131
+ def on_mount(self) -> None:
132
+ self.query_one("#agent-name", Input).focus()
133
+
134
+ def action_close(self) -> None:
135
+ self.dismiss(None)
136
+
137
+ def _set_busy(self, busy: bool) -> None:
138
+ self.query_one("#agent-name", Input).disabled = busy
139
+ self.query_one("#share-url", Input).disabled = busy
140
+ self.query_one("#share-password", Input).disabled = busy
141
+ self.query_one("#create", Button).disabled = busy
142
+ self.query_one("#cancel", Button).disabled = busy
143
+ self.query_one("#import", Button).disabled = busy
144
+
145
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
146
+ button_id = event.button.id or ""
147
+
148
+ if button_id == "cancel":
149
+ self.dismiss(None)
150
+ return
151
+
152
+ if button_id == "create":
153
+ await self._create_agent()
154
+ return
155
+
156
+ if button_id == "import":
157
+ await self._import_agent()
158
+ return
159
+
160
+ async def on_input_submitted(self, event: Input.Submitted) -> None:
161
+ """Handle enter key in input fields."""
162
+ input_id = event.input.id or ""
163
+ if input_id == "agent-name":
164
+ await self._create_agent()
165
+ elif input_id in ("share-url", "share-password"):
166
+ await self._import_agent()
167
+
168
+ async def _create_agent(self) -> None:
169
+ """Create the agent profile."""
170
+ try:
171
+ from ...db import get_database
172
+
173
+ name_input = self.query_one("#agent-name", Input).value.strip()
174
+ name = name_input or self._default_name
175
+
176
+ agent_id = str(uuid.uuid4())
177
+
178
+ db = get_database(read_only=False)
179
+ record = db.get_or_create_agent_info(agent_id, name=name)
180
+
181
+ self.dismiss({
182
+ "id": record.id,
183
+ "name": record.name,
184
+ "description": record.description or "",
185
+ })
186
+ except Exception as e:
187
+ logger.error(f"Failed to create agent: {e}")
188
+ self.app.notify(f"Failed to create agent: {e}", severity="error")
189
+
190
+ async def _import_agent(self) -> None:
191
+ """Import an agent from a share link."""
192
+ share_url = self.query_one("#share-url", Input).value.strip()
193
+ password = self.query_one("#share-password", Input).value.strip() or None
194
+
195
+ if not share_url:
196
+ self.app.notify("Please enter a share URL", severity="warning")
197
+ self.query_one("#share-url", Input).focus()
198
+ return
199
+
200
+ if "/share/" not in share_url:
201
+ self.app.notify("Invalid share URL format", severity="warning")
202
+ self.query_one("#share-url", Input).focus()
203
+ return
204
+
205
+ status = self.query_one("#import-status", Static)
206
+ status.update("Importing...")
207
+ self._set_busy(True)
208
+
209
+ def do_import() -> dict:
210
+ from ...commands.import_shares import import_agent_from_share
211
+
212
+ return import_agent_from_share(share_url, password=password)
213
+
214
+ self._import_worker = self.run_worker(do_import, thread=True, exit_on_error=False)
215
+
216
+ def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
217
+ if self._import_worker is None or event.worker is not self._import_worker:
218
+ return
219
+
220
+ status = self.query_one("#import-status", Static)
221
+
222
+ if event.state == WorkerState.ERROR:
223
+ err = self._import_worker.error if self._import_worker else "Unknown error"
224
+ status.update(f"Error: {err}")
225
+ self._set_busy(False)
226
+ return
227
+
228
+ if event.state != WorkerState.SUCCESS:
229
+ return
230
+
231
+ result = self._import_worker.result if self._import_worker else {}
232
+ if not result or not result.get("success"):
233
+ error_msg = (result or {}).get("error", "Import failed")
234
+ status.update(f"Error: {error_msg}")
235
+ self._set_busy(False)
236
+ return
237
+
238
+ self.dismiss({
239
+ "id": result["agent_id"],
240
+ "name": result["agent_name"],
241
+ "description": result.get("agent_description", ""),
242
+ "imported": True,
243
+ "sessions_imported": result.get("sessions_imported", 0),
244
+ })
@@ -6,9 +6,6 @@ import contextlib
6
6
  from datetime import datetime
7
7
  import io
8
8
  import json
9
- import os
10
- import shutil
11
- import subprocess
12
9
  from typing import Optional
13
10
 
14
11
  from rich.markup import escape
@@ -20,6 +17,7 @@ from textual.worker import Worker, WorkerState
20
17
  from textual.widgets import DataTable, Static, TextArea
21
18
 
22
19
  from ..widgets.openable_table import OpenableDataTable
20
+ from ..clipboard import copy_text
23
21
 
24
22
 
25
23
  def _format_dt(dt: object) -> str:
@@ -246,36 +244,17 @@ class EventDetailScreen(ModalScreen):
246
244
  pass
247
245
 
248
246
  if exit_code == 0 and share_link:
247
+ from ..clipboard import copy_text
248
+
249
249
  if not slack_message:
250
250
  slack_message = getattr(self._event, "slack_message", None) if self._event else None
251
251
 
252
252
  if slack_message:
253
- copy_text = str(slack_message) + "\n\n" + str(share_link)
253
+ text_to_copy = str(slack_message) + "\n\n" + str(share_link)
254
254
  else:
255
- copy_text = str(share_link)
255
+ text_to_copy = str(share_link)
256
256
 
257
- copied = False
258
- if os.environ.get("TMUX") and shutil.which("pbcopy"):
259
- try:
260
- copied = (
261
- subprocess.run(
262
- ["pbcopy"],
263
- input=copy_text,
264
- text=True,
265
- capture_output=False,
266
- check=False,
267
- ).returncode
268
- == 0
269
- )
270
- except Exception:
271
- copied = False
272
-
273
- if not copied:
274
- try:
275
- self.app.copy_to_clipboard(copy_text)
276
- copied = True
277
- except Exception:
278
- copied = False
257
+ copied = copy_text(self.app, text_to_copy)
279
258
 
280
259
  suffix = " (copied)" if copied else ""
281
260
  self.app.notify(f"Share link: {share_link}{suffix}", title="Share", timeout=6)