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,1248 @@
1
+ """Agents Panel Widget - Lists agent profiles with their terminals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ import shlex
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from textual import events
12
+ from textual.app import ComposeResult
13
+ from textual.containers import Container, Horizontal, Vertical
14
+ from textual.widgets import Button, Static
15
+ from textual.message import Message
16
+ from textual.worker import Worker, WorkerState
17
+ from rich.text import Text
18
+
19
+ from .. import tmux_manager
20
+ from ...logging_config import setup_logger
21
+ from ..clipboard import copy_text
22
+
23
+ logger = setup_logger("realign.dashboard.widgets.agents_panel", "dashboard.log")
24
+
25
+
26
+ class AgentNameButton(Button):
27
+ """Button that emits a message on double-click."""
28
+
29
+ class DoubleClicked(Message, bubble=True):
30
+ def __init__(self, button: "AgentNameButton", agent_id: str) -> None:
31
+ super().__init__()
32
+ self.button = button
33
+ self.agent_id = agent_id
34
+
35
+ async def _on_click(self, event: events.Click) -> None:
36
+ await super()._on_click(event)
37
+ if event.chain >= 2:
38
+ self.post_message(self.DoubleClicked(self, self.name or ""))
39
+
40
+
41
+ class AgentsPanel(Container, can_focus=True):
42
+ """Panel displaying agent profiles with their associated terminals."""
43
+
44
+ DEFAULT_CSS = """
45
+ AgentsPanel {
46
+ height: 100%;
47
+ padding: 0 1;
48
+ overflow: hidden;
49
+ }
50
+
51
+ AgentsPanel:focus {
52
+ border: none;
53
+ }
54
+
55
+ AgentsPanel .summary {
56
+ height: auto;
57
+ margin: 0 0 1 0;
58
+ padding: 0;
59
+ background: transparent;
60
+ border: none;
61
+ }
62
+
63
+ AgentsPanel Button {
64
+ min-width: 0;
65
+ padding: 0 1;
66
+ background: transparent;
67
+ border: none;
68
+ }
69
+
70
+ AgentsPanel Button:hover {
71
+ background: $surface-lighten-1;
72
+ }
73
+
74
+ AgentsPanel .summary Button {
75
+ width: auto;
76
+ margin-right: 1;
77
+ }
78
+
79
+ AgentsPanel .list {
80
+ height: 1fr;
81
+ padding: 0;
82
+ overflow-y: auto;
83
+ border: none;
84
+ background: transparent;
85
+ }
86
+
87
+ AgentsPanel .agent-row {
88
+ height: auto;
89
+ min-height: 2;
90
+ margin: 0 0 0 0;
91
+ }
92
+
93
+ AgentsPanel .agent-row Button.agent-name {
94
+ width: 1fr;
95
+ height: 2;
96
+ margin: 0;
97
+ padding: 0 1;
98
+ text-align: left;
99
+ content-align: left middle;
100
+ }
101
+
102
+ AgentsPanel .agent-row Button.agent-create {
103
+ width: auto;
104
+ min-width: 8;
105
+ height: 2;
106
+ margin-left: 1;
107
+ padding: 0 1;
108
+ content-align: center middle;
109
+ }
110
+
111
+ AgentsPanel .agent-row Button.agent-delete {
112
+ width: 3;
113
+ min-width: 3;
114
+ height: 2;
115
+ margin-left: 1;
116
+ padding: 0;
117
+ content-align: center middle;
118
+ }
119
+
120
+ AgentsPanel .agent-row Button.agent-share {
121
+ width: auto;
122
+ min-width: 8;
123
+ height: 2;
124
+ margin-left: 1;
125
+ padding: 0 1;
126
+ content-align: center middle;
127
+ }
128
+
129
+ AgentsPanel .terminal-list {
130
+ margin: 0 0 1 2;
131
+ padding: 0;
132
+ height: auto;
133
+ background: transparent;
134
+ border: none;
135
+ }
136
+
137
+ AgentsPanel .terminal-row {
138
+ height: auto;
139
+ min-height: 2;
140
+ margin: 0;
141
+ }
142
+
143
+ AgentsPanel .terminal-row Button.terminal-switch {
144
+ width: 1fr;
145
+ height: 2;
146
+ margin: 0;
147
+ padding: 0 1;
148
+ text-align: left;
149
+ content-align: left top;
150
+ }
151
+
152
+ AgentsPanel .terminal-row Button.terminal-close {
153
+ width: 3;
154
+ min-width: 3;
155
+ height: 2;
156
+ margin-left: 1;
157
+ padding: 0;
158
+ content-align: center middle;
159
+ }
160
+ """
161
+
162
+ def __init__(self) -> None:
163
+ super().__init__()
164
+ self._refresh_lock = asyncio.Lock()
165
+ self._agents: list[dict] = []
166
+ self._refresh_worker: Optional[Worker] = None
167
+ self._share_worker: Optional[Worker] = None
168
+ self._sync_worker: Optional[Worker] = None
169
+ self._share_agent_id: Optional[str] = None
170
+ self._sync_agent_id: Optional[str] = None
171
+ self._refresh_timer = None
172
+
173
+ def compose(self) -> ComposeResult:
174
+ with Horizontal(classes="summary"):
175
+ yield Button("+ Create Agent", id="create-agent", variant="primary")
176
+ with Vertical(id="agents-list", classes="list"):
177
+ yield Static("No agents yet. Click 'Create Agent' to add one.")
178
+
179
+ def on_show(self) -> None:
180
+ if self._refresh_timer is None:
181
+ self._refresh_timer = self.set_interval(30.0, self._on_refresh_timer)
182
+ else:
183
+ try:
184
+ self._refresh_timer.resume()
185
+ except Exception:
186
+ pass
187
+ self.refresh_data()
188
+
189
+ def on_hide(self) -> None:
190
+ if self._refresh_timer is not None:
191
+ try:
192
+ self._refresh_timer.pause()
193
+ except Exception:
194
+ pass
195
+
196
+ def _on_refresh_timer(self) -> None:
197
+ self.refresh_data()
198
+
199
+ def refresh_data(self) -> None:
200
+ if not self.display:
201
+ return
202
+ if self._refresh_worker is not None and self._refresh_worker.state in (
203
+ WorkerState.PENDING,
204
+ WorkerState.RUNNING,
205
+ ):
206
+ return
207
+ self._refresh_worker = self.run_worker(
208
+ self._collect_agents, thread=True, exit_on_error=False
209
+ )
210
+
211
+ def _collect_agents(self) -> list[dict]:
212
+ """Collect agent info with their terminals."""
213
+ agents = []
214
+ try:
215
+ from ...db import get_database
216
+
217
+ db = get_database(read_only=True)
218
+ agent_infos = db.list_agent_info()
219
+ active_terminals = db.list_agents(status="active", limit=1000)
220
+
221
+ # Latest window links per terminal (V23)
222
+ latest_links = db.list_latest_window_links(limit=2000)
223
+ link_by_terminal = {l.terminal_id: l for l in latest_links if l.terminal_id}
224
+
225
+ # Get tmux windows to retrieve window id and fallback session_id
226
+ tmux_windows = tmux_manager.list_inner_windows()
227
+ terminal_to_window = {
228
+ w.terminal_id: w for w in tmux_windows if w.terminal_id
229
+ }
230
+
231
+ # Collect all session_ids for title lookup
232
+ session_ids: list[str] = []
233
+ for t in active_terminals:
234
+ link = link_by_terminal.get(t.id)
235
+ if link and link.session_id:
236
+ session_ids.append(link.session_id)
237
+ continue
238
+ window = terminal_to_window.get(t.id)
239
+ if window and window.session_id:
240
+ session_ids.append(window.session_id)
241
+
242
+ titles = self._fetch_session_titles(session_ids)
243
+
244
+ # Map agent_info.id -> list of terminals
245
+ agent_to_terminals: dict[str, list[dict]] = {}
246
+ for t in active_terminals:
247
+ # Find which agent_info this terminal belongs to
248
+ agent_info_id = None
249
+
250
+ link = link_by_terminal.get(t.id)
251
+
252
+ # Method 1: Check source field for "agent:{agent_info_id}" format
253
+ source = t.source or ""
254
+ if source.startswith("agent:"):
255
+ agent_info_id = source[6:]
256
+
257
+ # Method 2: WindowLink agent_id
258
+ if not agent_info_id and link and link.agent_id:
259
+ agent_info_id = link.agent_id
260
+
261
+ # Method 3: Fallback - check tmux window's session.agent_id
262
+ if not agent_info_id:
263
+ window = terminal_to_window.get(t.id)
264
+ if window and window.session_id:
265
+ session = db.get_session_by_id(window.session_id)
266
+ if session:
267
+ agent_info_id = session.agent_id
268
+
269
+ if agent_info_id:
270
+ if agent_info_id not in agent_to_terminals:
271
+ agent_to_terminals[agent_info_id] = []
272
+
273
+ # Get session_id from windowlink (preferred) or tmux window
274
+ window = terminal_to_window.get(t.id)
275
+ session_id = (
276
+ link.session_id if link and link.session_id else (window.session_id if window else None)
277
+ )
278
+ title = titles.get(session_id, "") if session_id else ""
279
+
280
+ agent_to_terminals[agent_info_id].append(
281
+ {
282
+ "terminal_id": t.id,
283
+ "session_id": session_id,
284
+ "provider": link.provider if link and link.provider else (t.provider or ""),
285
+ "session_type": t.session_type or "",
286
+ "title": title,
287
+ "cwd": t.cwd or "",
288
+ }
289
+ )
290
+
291
+ for info in agent_infos:
292
+ terminals = agent_to_terminals.get(info.id, [])
293
+ agents.append(
294
+ {
295
+ "id": info.id,
296
+ "name": info.name,
297
+ "description": info.description or "",
298
+ "terminals": terminals,
299
+ "share_url": getattr(info, "share_url", None),
300
+ "last_synced_at": getattr(info, "last_synced_at", None),
301
+ }
302
+ )
303
+ except Exception as e:
304
+ logger.debug(f"Failed to collect agents: {e}")
305
+ return agents
306
+
307
+
308
+ def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
309
+ # Handle refresh worker
310
+ if self._refresh_worker is not None and event.worker is self._refresh_worker:
311
+ if event.state == WorkerState.ERROR:
312
+ self._agents = []
313
+ elif event.state == WorkerState.SUCCESS:
314
+ self._agents = self._refresh_worker.result or []
315
+ else:
316
+ return
317
+ self.run_worker(
318
+ self._render_agents(), group="agents-render", exclusive=True
319
+ )
320
+ return
321
+
322
+ # Handle share worker
323
+ if self._share_worker is not None and event.worker is self._share_worker:
324
+ self._handle_share_worker_state_changed(event)
325
+
326
+ # Handle sync worker
327
+ if self._sync_worker is not None and event.worker is self._sync_worker:
328
+ self._handle_sync_worker_state_changed(event)
329
+
330
+ async def _render_agents(self) -> None:
331
+ async with self._refresh_lock:
332
+ try:
333
+ container = self.query_one("#agents-list", Vertical)
334
+ except Exception:
335
+ return
336
+
337
+ await container.remove_children()
338
+
339
+ if not self._agents:
340
+ await container.mount(
341
+ Static("No agents yet. Click 'Create Agent' to add one.")
342
+ )
343
+ return
344
+
345
+ for agent in self._agents:
346
+ safe_id = self._safe_id(agent["id"])
347
+
348
+ # Agent row with name, create button, and delete button
349
+ row = Horizontal(classes="agent-row")
350
+ await container.mount(row)
351
+
352
+ # Agent name button
353
+ name_label = Text(agent["name"], style="bold")
354
+ terminal_count = len(agent["terminals"])
355
+ if terminal_count > 0:
356
+ name_label.append(f" ({terminal_count})", style="dim")
357
+
358
+ await row.mount(
359
+ AgentNameButton(
360
+ name_label,
361
+ id=f"agent-{safe_id}",
362
+ name=agent["id"],
363
+ classes="agent-name",
364
+ )
365
+ )
366
+
367
+ # Share or Sync button (Sync if agent already has a share_url)
368
+ if agent.get("share_url"):
369
+ await row.mount(
370
+ Button(
371
+ "Sync",
372
+ id=f"sync-{safe_id}",
373
+ name=agent["id"],
374
+ classes="agent-share",
375
+ )
376
+ )
377
+ else:
378
+ await row.mount(
379
+ Button(
380
+ "Share",
381
+ id=f"share-{safe_id}",
382
+ name=agent["id"],
383
+ classes="agent-share",
384
+ )
385
+ )
386
+
387
+ # Create terminal button
388
+ await row.mount(
389
+ Button(
390
+ "+ Term",
391
+ id=f"create-term-{safe_id}",
392
+ name=agent["id"],
393
+ classes="agent-create",
394
+ )
395
+ )
396
+
397
+ # Delete agent button
398
+ await row.mount(
399
+ Button(
400
+ "✕",
401
+ id=f"delete-{safe_id}",
402
+ name=agent["id"],
403
+ variant="error",
404
+ classes="agent-delete",
405
+ )
406
+ )
407
+
408
+ # Terminal list (indented under agent)
409
+ if agent["terminals"]:
410
+ term_list = Vertical(classes="terminal-list")
411
+ await container.mount(term_list)
412
+
413
+ for term in agent["terminals"]:
414
+ term_safe_id = self._safe_id(term["terminal_id"])
415
+ term_row = Horizontal(classes="terminal-row")
416
+ await term_list.mount(term_row)
417
+
418
+ label = self._make_terminal_label(term)
419
+ await term_row.mount(
420
+ Button(
421
+ label,
422
+ id=f"switch-{term_safe_id}",
423
+ name=term["terminal_id"],
424
+ classes="terminal-switch",
425
+ )
426
+ )
427
+ await term_row.mount(
428
+ Button(
429
+ "✕",
430
+ id=f"close-{term_safe_id}",
431
+ name=term["terminal_id"],
432
+ variant="error",
433
+ classes="terminal-close",
434
+ )
435
+ )
436
+
437
+ def _make_terminal_label(self, term: dict) -> Text:
438
+ """Generate label for a terminal."""
439
+ provider = term.get("provider", "")
440
+ session_id = term.get("session_id", "")
441
+ title = term.get("title", "")
442
+
443
+ label = Text(no_wrap=True, overflow="ellipsis")
444
+
445
+ # First line: title or provider
446
+ if title:
447
+ label.append(title)
448
+ elif provider:
449
+ label.append(provider.capitalize())
450
+ else:
451
+ label.append("Terminal")
452
+
453
+ label.append("\n")
454
+
455
+ # Second line: [provider] session_id
456
+ if provider:
457
+ detail = f"[{provider.capitalize()}]"
458
+ else:
459
+ detail = ""
460
+ if session_id:
461
+ detail = f"{detail} #{self._short_id(session_id)}"
462
+
463
+ label.append(detail, style="dim")
464
+ return label
465
+
466
+ @staticmethod
467
+ def _short_id(val: str | None) -> str:
468
+ if not val:
469
+ return ""
470
+ if len(val) > 20:
471
+ return val[:8] + "..." + val[-8:]
472
+ return val
473
+
474
+ def _fetch_session_titles(self, session_ids: list[str]) -> dict[str, str]:
475
+ """Fetch session titles from database (same method as Terminal panel)."""
476
+ if not session_ids:
477
+ return {}
478
+ try:
479
+ from ...db import get_database
480
+
481
+ db = get_database(read_only=True)
482
+ sessions = db.get_sessions_by_ids(session_ids)
483
+ titles: dict[str, str] = {}
484
+ for s in sessions:
485
+ title = (s.session_title or "").strip()
486
+ if title:
487
+ titles[s.id] = title
488
+ return titles
489
+ except Exception:
490
+ return {}
491
+
492
+ @staticmethod
493
+ def _safe_id(raw: str) -> str:
494
+ safe = re.sub(r"[^A-Za-z0-9_-]+", "-", raw).strip("-_")
495
+ if not safe:
496
+ return "a"
497
+ if safe[0].isdigit():
498
+ return f"a-{safe}"
499
+ return safe
500
+
501
+ def _find_window(self, terminal_id: str) -> str | None:
502
+ if not terminal_id:
503
+ return None
504
+ try:
505
+ for w in tmux_manager.list_inner_windows():
506
+ if w.terminal_id == terminal_id:
507
+ return w.window_id
508
+ except Exception:
509
+ pass
510
+ return None
511
+
512
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
513
+ btn_id = event.button.id or ""
514
+
515
+ if btn_id == "create-agent":
516
+ await self._create_agent()
517
+ return
518
+
519
+ if btn_id.startswith("agent-"):
520
+ # Click on agent name - could expand/collapse or do nothing
521
+ return
522
+
523
+ if btn_id.startswith("create-term-"):
524
+ agent_id = event.button.name or ""
525
+ await self._create_terminal_for_agent(agent_id)
526
+ return
527
+
528
+ if btn_id.startswith("delete-"):
529
+ agent_id = event.button.name or ""
530
+ await self._delete_agent(agent_id)
531
+ return
532
+
533
+ if btn_id.startswith("share-"):
534
+ agent_id = event.button.name or ""
535
+ await self._share_agent(agent_id)
536
+ return
537
+
538
+ if btn_id.startswith("sync-"):
539
+ agent_id = event.button.name or ""
540
+ await self._sync_agent(agent_id)
541
+ return
542
+
543
+ if btn_id.startswith("switch-"):
544
+ terminal_id = event.button.name or ""
545
+ await self._switch_to_terminal(terminal_id)
546
+ return
547
+
548
+ if btn_id.startswith("close-"):
549
+ terminal_id = event.button.name or ""
550
+ await self._close_terminal(terminal_id)
551
+ return
552
+
553
+ async def on_agent_name_button_double_clicked(
554
+ self, event: AgentNameButton.DoubleClicked
555
+ ) -> None:
556
+ agent_id = event.agent_id
557
+ if not agent_id:
558
+ return
559
+
560
+ from ..screens import AgentDetailScreen
561
+
562
+ self.app.push_screen(AgentDetailScreen(agent_id))
563
+
564
+ async def _create_agent(self) -> None:
565
+ try:
566
+ from ..screens import CreateAgentInfoScreen
567
+
568
+ self.app.push_screen(CreateAgentInfoScreen(), self._on_create_result)
569
+ except ImportError:
570
+ try:
571
+ from ...agent_names import generate_agent_name
572
+ from ...db import get_database
573
+ import uuid
574
+
575
+ db = get_database(read_only=False)
576
+ agent_id = str(uuid.uuid4())
577
+ name = generate_agent_name()
578
+ db.get_or_create_agent_info(agent_id, name=name)
579
+ self.app.notify(f"Created: {name}", title="Agent")
580
+ self.refresh_data()
581
+ except Exception as e:
582
+ self.app.notify(f"Failed: {e}", title="Agent", severity="error")
583
+
584
+ def _on_create_result(self, result: dict | None) -> None:
585
+ if result:
586
+ if result.get("imported"):
587
+ n = result.get("sessions_imported", 0)
588
+ self.app.notify(
589
+ f"Imported: {result.get('name')} ({n} sessions)", title="Agent"
590
+ )
591
+ else:
592
+ self.app.notify(f"Created: {result.get('name')}", title="Agent")
593
+ self.refresh_data()
594
+
595
+ async def _create_terminal_for_agent(self, agent_id: str) -> None:
596
+ """Create a new terminal under the specified agent."""
597
+ if not agent_id:
598
+ return
599
+
600
+ # Get agent info
601
+ agent = next((a for a in self._agents if a["id"] == agent_id), None)
602
+ if not agent:
603
+ self.app.notify("Agent not found", title="Agent", severity="error")
604
+ return
605
+
606
+ # Show create terminal screen with agent context
607
+ try:
608
+ from ..screens import CreateAgentScreen
609
+
610
+ self.app.push_screen(
611
+ CreateAgentScreen(),
612
+ lambda result: self._on_create_terminal_result(result, agent_id),
613
+ )
614
+ except ImportError as e:
615
+ self.app.notify(f"Failed: {e}", title="Agent", severity="error")
616
+
617
+ def _on_create_terminal_result(
618
+ self, result: tuple[str, str, bool, bool] | None, agent_id: str
619
+ ) -> None:
620
+ """Handle result from CreateAgentScreen."""
621
+ if result is None:
622
+ return
623
+
624
+ agent_type, workspace, skip_permissions, no_track = result
625
+
626
+ # Create the terminal with agent association
627
+ self.run_worker(
628
+ self._do_create_terminal(
629
+ agent_type, workspace, skip_permissions, no_track, agent_id
630
+ ),
631
+ group="terminal-create",
632
+ exclusive=True,
633
+ )
634
+
635
+ async def _do_create_terminal(
636
+ self,
637
+ agent_type: str,
638
+ workspace: str,
639
+ skip_permissions: bool,
640
+ no_track: bool,
641
+ agent_id: str,
642
+ ) -> None:
643
+ """Actually create the terminal with agent association."""
644
+ if agent_type == "claude":
645
+ await self._create_claude_terminal(
646
+ workspace, skip_permissions, no_track, agent_id
647
+ )
648
+ elif agent_type == "codex":
649
+ await self._create_codex_terminal(workspace, no_track, agent_id)
650
+ elif agent_type == "opencode":
651
+ await self._create_opencode_terminal(workspace, agent_id)
652
+ elif agent_type == "zsh":
653
+ await self._create_zsh_terminal(workspace, agent_id)
654
+
655
+ self.refresh_data()
656
+
657
+ async def _create_claude_terminal(
658
+ self, workspace: str, skip_permissions: bool, no_track: bool, agent_id: str
659
+ ) -> None:
660
+ """Create a Claude terminal associated with an agent."""
661
+ terminal_id = tmux_manager.new_terminal_id()
662
+ context_id = tmux_manager.new_context_id("cc")
663
+
664
+ # Prepare CODEX_HOME so user can run codex in this terminal
665
+ try:
666
+ from ...codex_home import prepare_codex_home
667
+
668
+ codex_home = prepare_codex_home(terminal_id)
669
+ except Exception:
670
+ codex_home = None
671
+
672
+ env = {
673
+ tmux_manager.ENV_TERMINAL_ID: terminal_id,
674
+ tmux_manager.ENV_TERMINAL_PROVIDER: "claude",
675
+ tmux_manager.ENV_INNER_SOCKET: tmux_manager.INNER_SOCKET,
676
+ tmux_manager.ENV_INNER_SESSION: tmux_manager.INNER_SESSION,
677
+ tmux_manager.ENV_CONTEXT_ID: context_id,
678
+ "ALINE_AGENT_ID": agent_id, # Pass agent_id to hooks
679
+ }
680
+ if codex_home:
681
+ env["CODEX_HOME"] = str(codex_home)
682
+ if no_track:
683
+ env["ALINE_NO_TRACK"] = "1"
684
+
685
+ # Install hooks
686
+ self._install_claude_hooks(workspace)
687
+
688
+ claude_cmd = "claude"
689
+ if skip_permissions:
690
+ claude_cmd = "claude --dangerously-skip-permissions"
691
+
692
+ command = self._command_in_directory(
693
+ tmux_manager.zsh_run_and_keep_open(claude_cmd), workspace
694
+ )
695
+
696
+ created = tmux_manager.create_inner_window(
697
+ "cc",
698
+ tmux_manager.shell_command_with_env(command, env),
699
+ terminal_id=terminal_id,
700
+ provider="claude",
701
+ context_id=context_id,
702
+ no_track=no_track,
703
+ )
704
+
705
+ if created:
706
+ # Store agent association in database with agent_info_id in source
707
+ try:
708
+ from ...db import get_database
709
+
710
+ db = get_database(read_only=False)
711
+ db.get_or_create_agent(
712
+ terminal_id,
713
+ provider="claude",
714
+ session_type="claude",
715
+ context_id=context_id,
716
+ cwd=workspace,
717
+ project_dir=workspace,
718
+ source=f"agent:{agent_id}", # Store agent_info_id in source
719
+ )
720
+ except Exception:
721
+ pass
722
+ else:
723
+ self.app.notify(
724
+ "Failed to create terminal", title="Agent", severity="error"
725
+ )
726
+
727
+ async def _create_codex_terminal(
728
+ self, workspace: str, no_track: bool, agent_id: str
729
+ ) -> None:
730
+ """Create a Codex terminal associated with an agent."""
731
+ try:
732
+ from ...db import get_database
733
+ from datetime import datetime, timedelta
734
+
735
+ db = get_database(read_only=True)
736
+ cutoff = datetime.now() - timedelta(seconds=10)
737
+ for agent in db.list_agents(status="active", limit=1000):
738
+ if agent.provider != "codex":
739
+ continue
740
+ if (agent.source or "") != f"agent:{agent_id}":
741
+ continue
742
+ if agent.created_at >= cutoff and not agent.session_id:
743
+ self.app.notify(
744
+ "Please wait a few seconds before opening another Codex terminal for this agent.",
745
+ title="Agent",
746
+ severity="warning",
747
+ )
748
+ return
749
+ except Exception:
750
+ pass
751
+
752
+ terminal_id = tmux_manager.new_terminal_id()
753
+ context_id = tmux_manager.new_context_id("cx")
754
+
755
+ try:
756
+ from ...codex_home import prepare_codex_home
757
+
758
+ codex_home = prepare_codex_home(terminal_id, agent_id=agent_id)
759
+ except Exception:
760
+ codex_home = None
761
+
762
+ env = {
763
+ tmux_manager.ENV_TERMINAL_ID: terminal_id,
764
+ tmux_manager.ENV_TERMINAL_PROVIDER: "codex",
765
+ tmux_manager.ENV_INNER_SOCKET: tmux_manager.INNER_SOCKET,
766
+ tmux_manager.ENV_INNER_SESSION: tmux_manager.INNER_SESSION,
767
+ tmux_manager.ENV_CONTEXT_ID: context_id,
768
+ "ALINE_AGENT_ID": agent_id,
769
+ }
770
+ if codex_home:
771
+ env["CODEX_HOME"] = str(codex_home)
772
+ if no_track:
773
+ env["ALINE_NO_TRACK"] = "1"
774
+
775
+ # Store agent in database with agent_info_id in source
776
+ try:
777
+ from ...db import get_database
778
+
779
+ db = get_database(read_only=False)
780
+ db.get_or_create_agent(
781
+ terminal_id,
782
+ provider="codex",
783
+ session_type="codex",
784
+ context_id=context_id,
785
+ cwd=workspace,
786
+ project_dir=workspace,
787
+ source=f"agent:{agent_id}", # Store agent_info_id in source
788
+ )
789
+ except Exception:
790
+ pass
791
+
792
+ command = self._command_in_directory(
793
+ tmux_manager.zsh_run_and_keep_open("codex"), workspace
794
+ )
795
+
796
+ created = tmux_manager.create_inner_window(
797
+ "codex",
798
+ tmux_manager.shell_command_with_env(command, env),
799
+ terminal_id=terminal_id,
800
+ provider="codex",
801
+ context_id=context_id,
802
+ no_track=no_track,
803
+ )
804
+
805
+ if not created:
806
+ self.app.notify(
807
+ "Failed to create terminal", title="Agent", severity="error"
808
+ )
809
+
810
+ async def _create_opencode_terminal(self, workspace: str, agent_id: str) -> None:
811
+ """Create an Opencode terminal associated with an agent."""
812
+ terminal_id = tmux_manager.new_terminal_id()
813
+ context_id = tmux_manager.new_context_id("oc")
814
+
815
+ # Prepare CODEX_HOME so user can run codex in this terminal
816
+ try:
817
+ from ...codex_home import prepare_codex_home
818
+
819
+ codex_home = prepare_codex_home(terminal_id, agent_id=agent_id)
820
+ except Exception:
821
+ codex_home = None
822
+
823
+ env = {
824
+ tmux_manager.ENV_TERMINAL_ID: terminal_id,
825
+ tmux_manager.ENV_TERMINAL_PROVIDER: "opencode",
826
+ tmux_manager.ENV_INNER_SOCKET: tmux_manager.INNER_SOCKET,
827
+ tmux_manager.ENV_INNER_SESSION: tmux_manager.INNER_SESSION,
828
+ tmux_manager.ENV_CONTEXT_ID: context_id,
829
+ "ALINE_AGENT_ID": agent_id,
830
+ }
831
+ if codex_home:
832
+ env["CODEX_HOME"] = str(codex_home)
833
+
834
+ # Install Claude hooks in case user runs claude manually
835
+ self._install_claude_hooks(workspace)
836
+
837
+ command = self._command_in_directory(
838
+ tmux_manager.zsh_run_and_keep_open("opencode"), workspace
839
+ )
840
+
841
+ created = tmux_manager.create_inner_window(
842
+ "opencode",
843
+ tmux_manager.shell_command_with_env(command, env),
844
+ terminal_id=terminal_id,
845
+ provider="opencode",
846
+ context_id=context_id,
847
+ )
848
+
849
+ if created:
850
+ # Store agent association in database
851
+ try:
852
+ from ...db import get_database
853
+
854
+ db = get_database(read_only=False)
855
+ db.get_or_create_agent(
856
+ terminal_id,
857
+ provider="opencode",
858
+ session_type="opencode",
859
+ context_id=context_id,
860
+ cwd=workspace,
861
+ project_dir=workspace,
862
+ source=f"agent:{agent_id}",
863
+ )
864
+ except Exception:
865
+ pass
866
+ else:
867
+ self.app.notify(
868
+ "Failed to create terminal", title="Agent", severity="error"
869
+ )
870
+
871
+ async def _create_zsh_terminal(self, workspace: str, agent_id: str) -> None:
872
+ """Create a zsh terminal associated with an agent."""
873
+ terminal_id = tmux_manager.new_terminal_id()
874
+ context_id = tmux_manager.new_context_id("zsh")
875
+
876
+ # Prepare CODEX_HOME so user can run codex in this terminal
877
+ try:
878
+ from ...codex_home import prepare_codex_home
879
+
880
+ codex_home = prepare_codex_home(terminal_id, agent_id=agent_id)
881
+ except Exception:
882
+ codex_home = None
883
+
884
+ env = {
885
+ tmux_manager.ENV_TERMINAL_ID: terminal_id,
886
+ tmux_manager.ENV_TERMINAL_PROVIDER: "zsh",
887
+ tmux_manager.ENV_INNER_SOCKET: tmux_manager.INNER_SOCKET,
888
+ tmux_manager.ENV_INNER_SESSION: tmux_manager.INNER_SESSION,
889
+ tmux_manager.ENV_CONTEXT_ID: context_id,
890
+ "ALINE_AGENT_ID": agent_id,
891
+ }
892
+ if codex_home:
893
+ env["CODEX_HOME"] = str(codex_home)
894
+
895
+ # Install Claude hooks in case user runs claude manually
896
+ self._install_claude_hooks(workspace)
897
+
898
+ command = self._command_in_directory("zsh", workspace)
899
+
900
+ created = tmux_manager.create_inner_window(
901
+ "zsh",
902
+ tmux_manager.shell_command_with_env(command, env),
903
+ terminal_id=terminal_id,
904
+ provider="zsh",
905
+ context_id=context_id,
906
+ )
907
+
908
+ if created:
909
+ # Store agent association in database
910
+ try:
911
+ from ...db import get_database
912
+
913
+ db = get_database(read_only=False)
914
+ db.get_or_create_agent(
915
+ terminal_id,
916
+ provider="zsh",
917
+ session_type="zsh",
918
+ context_id=context_id,
919
+ cwd=workspace,
920
+ project_dir=workspace,
921
+ source=f"agent:{agent_id}",
922
+ )
923
+ except Exception:
924
+ pass
925
+ else:
926
+ self.app.notify(
927
+ "Failed to create terminal", title="Agent", severity="error"
928
+ )
929
+
930
+ def _install_claude_hooks(self, workspace: str) -> None:
931
+ """Install Claude hooks for a workspace."""
932
+ try:
933
+ from ...claude_hooks.stop_hook_installer import (
934
+ ensure_stop_hook_installed,
935
+ get_settings_path as get_stop_settings_path,
936
+ install_stop_hook,
937
+ )
938
+ from ...claude_hooks.user_prompt_submit_hook_installer import (
939
+ ensure_user_prompt_submit_hook_installed,
940
+ get_settings_path as get_submit_settings_path,
941
+ install_user_prompt_submit_hook,
942
+ )
943
+ from ...claude_hooks.permission_request_hook_installer import (
944
+ ensure_permission_request_hook_installed,
945
+ get_settings_path as get_permission_settings_path,
946
+ install_permission_request_hook,
947
+ )
948
+
949
+ ensure_stop_hook_installed(quiet=True)
950
+ ensure_user_prompt_submit_hook_installed(quiet=True)
951
+ ensure_permission_request_hook_installed(quiet=True)
952
+
953
+ project_root = Path(workspace)
954
+ install_stop_hook(get_stop_settings_path(project_root), quiet=True)
955
+ install_user_prompt_submit_hook(
956
+ get_submit_settings_path(project_root), quiet=True
957
+ )
958
+ install_permission_request_hook(
959
+ get_permission_settings_path(project_root), quiet=True
960
+ )
961
+ except Exception:
962
+ pass
963
+
964
+ @staticmethod
965
+ def _command_in_directory(command: str, directory: str) -> str:
966
+ return f"cd {shlex.quote(directory)} && {command}"
967
+
968
+ async def _delete_agent(self, agent_id: str) -> None:
969
+ if not agent_id:
970
+ return
971
+ try:
972
+ from ...db import get_database
973
+
974
+ db = get_database(read_only=False)
975
+ info = db.get_agent_info(agent_id)
976
+ name = info.name if info else "Unknown"
977
+
978
+ record = db.update_agent_info(agent_id, visibility="invisible")
979
+ if record:
980
+ self.app.notify(f"Hidden: {name}", title="Agent")
981
+ self.refresh_data()
982
+ except Exception as e:
983
+ self.app.notify(f"Failed: {e}", title="Agent", severity="error")
984
+
985
+ async def _switch_to_terminal(self, terminal_id: str) -> None:
986
+ if not terminal_id:
987
+ return
988
+
989
+ window_id = self._find_window(terminal_id)
990
+ if not window_id:
991
+ self.app.notify("Window not found", title="Agent", severity="warning")
992
+ return
993
+
994
+ if tmux_manager.select_inner_window(window_id):
995
+ tmux_manager.focus_right_pane()
996
+ tmux_manager.clear_attention(window_id)
997
+ else:
998
+ self.app.notify("Failed to switch", title="Agent", severity="error")
999
+
1000
+ async def _close_terminal(self, terminal_id: str) -> None:
1001
+ if not terminal_id:
1002
+ return
1003
+
1004
+ # Try to close the tmux window if it exists
1005
+ window_id = self._find_window(terminal_id)
1006
+ if window_id:
1007
+ tmux_manager.kill_inner_window(window_id)
1008
+
1009
+ # Also update the agent status in the database to mark it as inactive
1010
+ try:
1011
+ from ...db import get_database
1012
+
1013
+ db = get_database(read_only=False)
1014
+ db.update_agent(terminal_id, status="inactive")
1015
+ except Exception as e:
1016
+ logger.debug(f"Failed to update agent status: {e}")
1017
+
1018
+ self.refresh_data()
1019
+
1020
+ async def _share_agent(self, agent_id: str) -> None:
1021
+ """Share all sessions for an agent."""
1022
+ if not agent_id:
1023
+ return
1024
+
1025
+ # Check if share is already in progress
1026
+ if self._share_worker is not None and self._share_worker.state in (
1027
+ WorkerState.PENDING,
1028
+ WorkerState.RUNNING,
1029
+ ):
1030
+ return
1031
+
1032
+ # Check if agent has sessions
1033
+ try:
1034
+ from ...db import get_database
1035
+
1036
+ db = get_database(read_only=True)
1037
+ sessions = db.get_sessions_by_agent_id(agent_id)
1038
+ if not sessions:
1039
+ self.app.notify(
1040
+ "Agent has no sessions to share", title="Share", severity="warning"
1041
+ )
1042
+ return
1043
+ except Exception as e:
1044
+ self.app.notify(
1045
+ f"Failed to check sessions: {e}", title="Share", severity="error"
1046
+ )
1047
+ return
1048
+
1049
+ # Store agent_id for the worker callback
1050
+ self._share_agent_id = agent_id
1051
+
1052
+ # Create progress callback that posts notifications from worker thread
1053
+ app = self.app # Capture reference for closure
1054
+
1055
+ def progress_callback(message: str) -> None:
1056
+ """Send progress notification from worker thread."""
1057
+ try:
1058
+ app.call_from_thread(app.notify, message, title="Share", timeout=3)
1059
+ except Exception:
1060
+ pass # Ignore errors if app is closing
1061
+
1062
+ def work() -> dict:
1063
+ import contextlib
1064
+ import io
1065
+ import json as json_module
1066
+ import re
1067
+
1068
+ stdout = io.StringIO()
1069
+ stderr = io.StringIO()
1070
+ with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
1071
+ from ...commands import export_shares
1072
+
1073
+ exit_code = export_shares.export_agent_shares_command(
1074
+ agent_id=agent_id,
1075
+ password=None,
1076
+ json_output=True,
1077
+ compact=True,
1078
+ progress_callback=progress_callback,
1079
+ )
1080
+
1081
+ output = stdout.getvalue().strip()
1082
+ error_text = stderr.getvalue().strip()
1083
+ result: dict = {
1084
+ "exit_code": exit_code,
1085
+ "output": output,
1086
+ "stderr": error_text,
1087
+ }
1088
+
1089
+ if output:
1090
+ try:
1091
+ result["json"] = json_module.loads(output)
1092
+ except Exception:
1093
+ result["json"] = None
1094
+ try:
1095
+ from ...llm_client import extract_json
1096
+
1097
+ result["json"] = extract_json(output)
1098
+ except Exception:
1099
+ result["json"] = None
1100
+ try:
1101
+ match = re.search(r"\{.*\}", output, re.DOTALL)
1102
+ if match:
1103
+ result["json"] = json_module.loads(
1104
+ match.group(0), strict=False
1105
+ )
1106
+ except Exception:
1107
+ result["json"] = None
1108
+
1109
+ if not result.get("json") and output:
1110
+ match = re.search(r"https?://[^\s\"']+/share/[^\s\"']+", output)
1111
+ if match:
1112
+ result["share_link_guess"] = match.group(0)
1113
+ else:
1114
+ result["json"] = None
1115
+
1116
+ return result
1117
+
1118
+ self.app.notify("Starting share...", title="Share", timeout=2)
1119
+ self._share_worker = self.run_worker(work, thread=True, exit_on_error=False)
1120
+
1121
+ def _handle_share_worker_state_changed(self, event: Worker.StateChanged) -> None:
1122
+ """Handle share worker state changes."""
1123
+ from ..clipboard import copy_text
1124
+
1125
+ if event.state == WorkerState.ERROR:
1126
+ err = self._share_worker.error if self._share_worker else "Unknown error"
1127
+ self.app.notify(f"Share failed: {err}", title="Share", severity="error")
1128
+ return
1129
+
1130
+ if event.state != WorkerState.SUCCESS:
1131
+ return
1132
+
1133
+ result = self._share_worker.result if self._share_worker else {}
1134
+ raw_exit_code = result.get("exit_code", None)
1135
+ exit_code = 1 if raw_exit_code is None else int(raw_exit_code)
1136
+ payload = result.get("json") or {}
1137
+ share_link = payload.get("share_link") or payload.get("share_url")
1138
+ if not share_link:
1139
+ share_link = result.get("share_link_guess")
1140
+ slack_message = (
1141
+ payload.get("slack_message") if isinstance(payload, dict) else None
1142
+ )
1143
+ if not slack_message:
1144
+ try:
1145
+ from ...db import get_database
1146
+
1147
+ db = get_database()
1148
+ agent_info = (
1149
+ db.get_agent_info(self._share_agent_id) if self._share_agent_id else None
1150
+ )
1151
+ agent_name = agent_info.name if agent_info and agent_info.name else "agent"
1152
+ slack_message = f"Sharing {agent_name} sessions from Aline."
1153
+ except Exception:
1154
+ slack_message = "Sharing sessions from Aline."
1155
+
1156
+ if exit_code == 0 and share_link:
1157
+ if slack_message:
1158
+ text_to_copy = str(slack_message) + "\n\n" + str(share_link)
1159
+ else:
1160
+ text_to_copy = str(share_link)
1161
+
1162
+ copied = copy_text(self.app, text_to_copy)
1163
+
1164
+ suffix = " (copied)" if copied else ""
1165
+ self.app.notify(
1166
+ f"Share link: {share_link}{suffix}", title="Share", timeout=6
1167
+ )
1168
+ elif exit_code == 0:
1169
+ self.app.notify("Share completed", title="Share", timeout=3)
1170
+ else:
1171
+ extra = result.get("stderr") or ""
1172
+ suffix = f": {extra}" if extra else ""
1173
+ self.app.notify(
1174
+ f"Share failed (exit {exit_code}){suffix}", title="Share", timeout=6
1175
+ )
1176
+
1177
+ async def _sync_agent(self, agent_id: str) -> None:
1178
+ """Sync all sessions for an agent with remote share."""
1179
+ if not agent_id:
1180
+ return
1181
+
1182
+ # Check if sync is already in progress
1183
+ if self._sync_worker is not None and self._sync_worker.state in (
1184
+ WorkerState.PENDING,
1185
+ WorkerState.RUNNING,
1186
+ ):
1187
+ return
1188
+
1189
+ self._sync_agent_id = agent_id
1190
+
1191
+ app = self.app
1192
+
1193
+ def progress_callback(message: str) -> None:
1194
+ try:
1195
+ app.call_from_thread(app.notify, message, title="Sync", timeout=3)
1196
+ except Exception:
1197
+ pass
1198
+
1199
+ def work() -> dict:
1200
+ from ...commands.sync_agent import sync_agent_command
1201
+
1202
+ return sync_agent_command(
1203
+ agent_id=agent_id,
1204
+ progress_callback=progress_callback,
1205
+ )
1206
+
1207
+ self.app.notify("Starting sync...", title="Sync", timeout=2)
1208
+ self._sync_worker = self.run_worker(work, thread=True, exit_on_error=False)
1209
+
1210
+ def _handle_sync_worker_state_changed(self, event: Worker.StateChanged) -> None:
1211
+ """Handle sync worker state changes."""
1212
+ if event.state == WorkerState.ERROR:
1213
+ err = self._sync_worker.error if self._sync_worker else "Unknown error"
1214
+ self.app.notify(f"Sync failed: {err}", title="Sync", severity="error")
1215
+ return
1216
+
1217
+ if event.state != WorkerState.SUCCESS:
1218
+ return
1219
+
1220
+ result = self._sync_worker.result if self._sync_worker else {}
1221
+
1222
+ if result.get("success"):
1223
+ pulled = result.get("sessions_pulled", 0)
1224
+ pushed = result.get("sessions_pushed", 0)
1225
+
1226
+ # Copy share URL to clipboard
1227
+ agent_id = self._sync_agent_id
1228
+ share_url = None
1229
+ if agent_id:
1230
+ agent = next((a for a in self._agents if a["id"] == agent_id), None)
1231
+ if agent:
1232
+ share_url = agent.get("share_url")
1233
+
1234
+ if share_url:
1235
+ copied = copy_text(self.app, share_url)
1236
+ suffix = " (link copied)" if copied else ""
1237
+ else:
1238
+ suffix = ""
1239
+
1240
+ self.app.notify(
1241
+ f"Synced: pulled {pulled}, pushed {pushed} session(s){suffix}",
1242
+ title="Sync",
1243
+ timeout=6,
1244
+ )
1245
+ self.refresh_data()
1246
+ else:
1247
+ error = result.get("error", "Unknown error")
1248
+ self.app.notify(f"Sync failed: {error}", title="Sync", severity="error")