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.
- {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/METADATA +1 -1
- {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/RECORD +41 -34
- realign/__init__.py +1 -1
- realign/agent_names.py +79 -0
- realign/claude_hooks/stop_hook.py +3 -0
- realign/claude_hooks/terminal_state.py +11 -0
- realign/claude_hooks/user_prompt_submit_hook.py +3 -0
- realign/cli.py +62 -0
- realign/codex_detector.py +1 -1
- realign/codex_home.py +46 -15
- realign/codex_terminal_linker.py +18 -7
- realign/commands/agent.py +109 -0
- realign/commands/doctor.py +3 -1
- realign/commands/export_shares.py +297 -0
- realign/commands/search.py +58 -29
- realign/dashboard/app.py +9 -158
- realign/dashboard/clipboard.py +54 -0
- realign/dashboard/screens/__init__.py +4 -0
- realign/dashboard/screens/agent_detail.py +333 -0
- realign/dashboard/screens/create_agent_info.py +133 -0
- realign/dashboard/screens/event_detail.py +6 -27
- realign/dashboard/styles/dashboard.tcss +67 -0
- realign/dashboard/tmux_manager.py +49 -8
- realign/dashboard/widgets/__init__.py +2 -0
- realign/dashboard/widgets/agents_panel.py +1129 -0
- realign/dashboard/widgets/config_panel.py +17 -11
- realign/dashboard/widgets/events_table.py +4 -27
- realign/dashboard/widgets/sessions_table.py +4 -27
- realign/dashboard/widgets/terminal_panel.py +109 -31
- realign/db/base.py +27 -0
- realign/db/locks.py +4 -0
- realign/db/schema.py +53 -2
- realign/db/sqlite_db.py +185 -2
- realign/events/agent_summarizer.py +157 -0
- realign/events/session_summarizer.py +25 -0
- realign/watcher_core.py +60 -3
- realign/worker_core.py +24 -1
- {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/WHEEL +0 -0
- {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/licenses/LICENSE +0 -0
- {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
|
+
)
|