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