aline-ai 0.7.2__py3-none-any.whl → 0.7.4__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.7.2.dist-info → aline_ai-0.7.4.dist-info}/METADATA +1 -1
- {aline_ai-0.7.2.dist-info → aline_ai-0.7.4.dist-info}/RECORD +32 -27
- realign/__init__.py +1 -1
- realign/adapters/codex.py +30 -2
- realign/claude_hooks/stop_hook.py +176 -21
- realign/codex_home.py +71 -0
- realign/codex_hooks/__init__.py +16 -0
- realign/codex_hooks/notify_hook.py +511 -0
- realign/codex_hooks/notify_hook_installer.py +247 -0
- realign/commands/doctor.py +125 -0
- realign/commands/export_shares.py +188 -65
- realign/commands/import_shares.py +30 -10
- realign/commands/init.py +16 -0
- realign/commands/sync_agent.py +274 -44
- realign/commit_pipeline.py +1024 -0
- realign/config.py +3 -11
- realign/dashboard/app.py +151 -2
- realign/dashboard/diagnostics.py +274 -0
- realign/dashboard/screens/create_agent.py +2 -1
- realign/dashboard/screens/create_agent_info.py +40 -77
- realign/dashboard/tmux_manager.py +348 -33
- realign/dashboard/widgets/agents_panel.py +942 -314
- realign/dashboard/widgets/config_panel.py +34 -121
- realign/dashboard/widgets/header.py +1 -1
- realign/db/sqlite_db.py +59 -1
- realign/logging_config.py +51 -6
- realign/watcher_core.py +742 -393
- realign/worker_core.py +206 -15
- {aline_ai-0.7.2.dist-info → aline_ai-0.7.4.dist-info}/WHEEL +0 -0
- {aline_ai-0.7.2.dist-info → aline_ai-0.7.4.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.7.2.dist-info → aline_ai-0.7.4.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.7.2.dist-info → aline_ai-0.7.4.dist-info}/top_level.txt +0 -0
|
@@ -6,13 +6,15 @@ import asyncio
|
|
|
6
6
|
import json as _json
|
|
7
7
|
import re
|
|
8
8
|
import shlex
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
9
11
|
from pathlib import Path
|
|
10
12
|
from typing import Optional
|
|
11
13
|
|
|
12
14
|
from textual import events
|
|
13
15
|
from textual.app import ComposeResult
|
|
14
16
|
from textual.containers import Container, Horizontal, Vertical
|
|
15
|
-
from textual.widgets import Button, Static
|
|
17
|
+
from textual.widgets import Button, Rule, Static
|
|
16
18
|
from textual.message import Message
|
|
17
19
|
from textual.worker import Worker, WorkerState
|
|
18
20
|
from rich.text import Text
|
|
@@ -24,6 +26,54 @@ from ..clipboard import copy_text
|
|
|
24
26
|
logger = setup_logger("realign.dashboard.widgets.agents_panel", "dashboard.log")
|
|
25
27
|
|
|
26
28
|
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class _ProcessSnapshot:
|
|
31
|
+
created_at_monotonic: float
|
|
32
|
+
tty_key: str
|
|
33
|
+
children: dict[int, list[int]]
|
|
34
|
+
comms: dict[int, str]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TruncButton(Button):
|
|
38
|
+
"""Button that truncates its label with '...' based on actual widget width."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, full_text: str, **kwargs) -> None:
|
|
41
|
+
super().__init__(full_text, **kwargs)
|
|
42
|
+
self._full_text = full_text
|
|
43
|
+
|
|
44
|
+
def render(self) -> Text:
|
|
45
|
+
width = self.size.width
|
|
46
|
+
gutter = self.styles.gutter
|
|
47
|
+
avail = width - gutter.width
|
|
48
|
+
text = self._full_text
|
|
49
|
+
if avail > 3 and len(text) > avail:
|
|
50
|
+
return Text(text[: avail - 3] + "...")
|
|
51
|
+
return Text(text)
|
|
52
|
+
|
|
53
|
+
class SpinnerPrefixButton(Button):
|
|
54
|
+
"""Button label with an animated spinner prefix, truncated to widget width."""
|
|
55
|
+
|
|
56
|
+
_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
57
|
+
|
|
58
|
+
def __init__(self, *, prefix: str, label: str, **kwargs) -> None:
|
|
59
|
+
self._prefix = prefix
|
|
60
|
+
self._label = label
|
|
61
|
+
super().__init__(f"{prefix}{label}", **kwargs)
|
|
62
|
+
|
|
63
|
+
def render(self) -> Text:
|
|
64
|
+
width = self.size.width
|
|
65
|
+
gutter = self.styles.gutter
|
|
66
|
+
avail = width - gutter.width
|
|
67
|
+
|
|
68
|
+
idx = int(time.monotonic() * 8) % len(self._FRAMES)
|
|
69
|
+
spinner = self._FRAMES[idx]
|
|
70
|
+
text = f"{self._prefix}{spinner} {self._label}"
|
|
71
|
+
|
|
72
|
+
if avail > 3 and len(text) > avail:
|
|
73
|
+
return Text(text[: avail - 3] + "...")
|
|
74
|
+
return Text(text)
|
|
75
|
+
|
|
76
|
+
|
|
27
77
|
class AgentNameButton(Button):
|
|
28
78
|
"""Button that emits a message on double-click."""
|
|
29
79
|
|
|
@@ -39,9 +89,173 @@ class AgentNameButton(Button):
|
|
|
39
89
|
self.post_message(self.DoubleClicked(self, self.name or ""))
|
|
40
90
|
|
|
41
91
|
|
|
92
|
+
class _TerminalRowWidget(Horizontal):
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
terminal_id: str,
|
|
97
|
+
term_safe_id: str,
|
|
98
|
+
prefix: str,
|
|
99
|
+
title: str,
|
|
100
|
+
running_agent: str | None,
|
|
101
|
+
pane_current_command: str | None,
|
|
102
|
+
is_active: bool,
|
|
103
|
+
) -> None:
|
|
104
|
+
super().__init__(classes="terminal-row")
|
|
105
|
+
|
|
106
|
+
switch_classes = "terminal-switch active-terminal" if is_active else "terminal-switch"
|
|
107
|
+
|
|
108
|
+
if title:
|
|
109
|
+
label = f"{prefix}{title}"
|
|
110
|
+
switch_btn: Button = TruncButton(
|
|
111
|
+
label,
|
|
112
|
+
id=f"switch-{term_safe_id}",
|
|
113
|
+
name=terminal_id,
|
|
114
|
+
classes=switch_classes,
|
|
115
|
+
)
|
|
116
|
+
switch_btn.tooltip = title
|
|
117
|
+
elif running_agent:
|
|
118
|
+
provider = (running_agent or "").strip().lower()
|
|
119
|
+
provider_label = (running_agent or "").strip().capitalize()
|
|
120
|
+
if provider in {"codex", "claude"}:
|
|
121
|
+
switch_btn = SpinnerPrefixButton(
|
|
122
|
+
prefix=prefix,
|
|
123
|
+
label=provider_label,
|
|
124
|
+
id=f"switch-{term_safe_id}",
|
|
125
|
+
name=terminal_id,
|
|
126
|
+
classes=f"{switch_classes} spinner",
|
|
127
|
+
)
|
|
128
|
+
else:
|
|
129
|
+
switch_btn = TruncButton(
|
|
130
|
+
f"{prefix}{provider_label}",
|
|
131
|
+
id=f"switch-{term_safe_id}",
|
|
132
|
+
name=terminal_id,
|
|
133
|
+
classes=switch_classes,
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
display = (pane_current_command or "").strip() or "Terminal"
|
|
137
|
+
switch_btn = TruncButton(
|
|
138
|
+
f"{prefix}{display}",
|
|
139
|
+
id=f"switch-{term_safe_id}",
|
|
140
|
+
name=terminal_id,
|
|
141
|
+
classes=switch_classes,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
self._switch_btn = switch_btn
|
|
145
|
+
self._close_btn = Button(
|
|
146
|
+
"✕",
|
|
147
|
+
id=f"close-{term_safe_id}",
|
|
148
|
+
name=terminal_id,
|
|
149
|
+
variant="error",
|
|
150
|
+
classes="terminal-close",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def compose(self) -> ComposeResult:
|
|
154
|
+
yield self._switch_btn
|
|
155
|
+
yield self._close_btn
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class _TerminalConnectorWidget(Horizontal):
|
|
159
|
+
def __init__(self) -> None:
|
|
160
|
+
super().__init__(classes="terminal-row")
|
|
161
|
+
|
|
162
|
+
def compose(self) -> ComposeResult:
|
|
163
|
+
yield Button("│", classes="terminal-switch")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class _AgentBlockWidget(Container):
|
|
167
|
+
def __init__(self, *, agent: dict, active_terminal_id: str | None) -> None:
|
|
168
|
+
super().__init__(classes="agent-block")
|
|
169
|
+
self._agent = agent
|
|
170
|
+
self._active_terminal_id = active_terminal_id
|
|
171
|
+
|
|
172
|
+
def compose(self) -> ComposeResult:
|
|
173
|
+
agent = self._agent
|
|
174
|
+
safe_id = AgentsPanel._safe_id(agent["id"])
|
|
175
|
+
|
|
176
|
+
rule = Rule(line_style="solid")
|
|
177
|
+
rule.styles.color = "black"
|
|
178
|
+
yield rule
|
|
179
|
+
|
|
180
|
+
with Horizontal(classes="agent-row"):
|
|
181
|
+
name_label = Text(f"▸ {agent['name']}", style="bold")
|
|
182
|
+
agent_btn = AgentNameButton(
|
|
183
|
+
name_label,
|
|
184
|
+
id=f"agent-{safe_id}",
|
|
185
|
+
name=agent["id"],
|
|
186
|
+
classes="agent-name",
|
|
187
|
+
)
|
|
188
|
+
if agent.get("description"):
|
|
189
|
+
agent_btn.tooltip = agent["description"]
|
|
190
|
+
yield agent_btn
|
|
191
|
+
|
|
192
|
+
if agent.get("share_url"):
|
|
193
|
+
yield Button(
|
|
194
|
+
"Sync",
|
|
195
|
+
id=f"sync-{safe_id}",
|
|
196
|
+
name=agent["id"],
|
|
197
|
+
classes="agent-share",
|
|
198
|
+
)
|
|
199
|
+
yield Button(
|
|
200
|
+
"Link",
|
|
201
|
+
id=f"link-{safe_id}",
|
|
202
|
+
name=agent["id"],
|
|
203
|
+
classes="agent-share",
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
yield Button(
|
|
207
|
+
"Share",
|
|
208
|
+
id=f"share-{safe_id}",
|
|
209
|
+
name=agent["id"],
|
|
210
|
+
classes="agent-share",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
yield Button(
|
|
214
|
+
"+",
|
|
215
|
+
id=f"create-term-{safe_id}",
|
|
216
|
+
name=agent["id"],
|
|
217
|
+
classes="agent-create",
|
|
218
|
+
)
|
|
219
|
+
yield Button(
|
|
220
|
+
"✕",
|
|
221
|
+
id=f"delete-{safe_id}",
|
|
222
|
+
name=agent["id"],
|
|
223
|
+
variant="error",
|
|
224
|
+
classes="agent-delete",
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
terminals = agent.get("terminals") or []
|
|
228
|
+
if not terminals:
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
with Vertical(classes="terminal-list"):
|
|
232
|
+
for term_idx, term in enumerate(terminals):
|
|
233
|
+
is_last_term = term_idx == len(terminals) - 1
|
|
234
|
+
prefix = "└ " if is_last_term else "├ "
|
|
235
|
+
|
|
236
|
+
terminal_id = term["terminal_id"]
|
|
237
|
+
term_safe_id = AgentsPanel._safe_id(terminal_id)
|
|
238
|
+
is_active = terminal_id == self._active_terminal_id
|
|
239
|
+
|
|
240
|
+
yield _TerminalRowWidget(
|
|
241
|
+
terminal_id=terminal_id,
|
|
242
|
+
term_safe_id=term_safe_id,
|
|
243
|
+
prefix=prefix,
|
|
244
|
+
title=term.get("title", "") or "",
|
|
245
|
+
running_agent=term.get("running_agent"),
|
|
246
|
+
pane_current_command=term.get("pane_current_command"),
|
|
247
|
+
is_active=is_active,
|
|
248
|
+
)
|
|
249
|
+
if not is_last_term:
|
|
250
|
+
yield _TerminalConnectorWidget()
|
|
251
|
+
|
|
252
|
+
|
|
42
253
|
class AgentsPanel(Container, can_focus=True):
|
|
43
254
|
"""Panel displaying agent profiles with their associated terminals."""
|
|
44
255
|
|
|
256
|
+
REFRESH_INTERVAL_SECONDS = 2.0
|
|
257
|
+
SPINNER_INTERVAL_SECONDS = 0.15
|
|
258
|
+
|
|
45
259
|
DEFAULT_CSS = """
|
|
46
260
|
AgentsPanel {
|
|
47
261
|
height: 100%;
|
|
@@ -55,10 +269,11 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
55
269
|
|
|
56
270
|
AgentsPanel .summary {
|
|
57
271
|
height: auto;
|
|
58
|
-
margin: 0
|
|
272
|
+
margin: 0;
|
|
59
273
|
padding: 0;
|
|
60
274
|
background: transparent;
|
|
61
275
|
border: none;
|
|
276
|
+
align: right middle;
|
|
62
277
|
}
|
|
63
278
|
|
|
64
279
|
AgentsPanel Button {
|
|
@@ -85,15 +300,22 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
85
300
|
background: transparent;
|
|
86
301
|
}
|
|
87
302
|
|
|
303
|
+
AgentsPanel Rule {
|
|
304
|
+
margin: 0;
|
|
305
|
+
}
|
|
306
|
+
|
|
88
307
|
AgentsPanel .agent-row {
|
|
308
|
+
height: 2;
|
|
309
|
+
margin: 0;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
AgentsPanel .agent-block {
|
|
89
313
|
height: auto;
|
|
90
|
-
min-height: 2;
|
|
91
|
-
margin: 0 0 0 0;
|
|
92
314
|
}
|
|
93
315
|
|
|
94
316
|
AgentsPanel .agent-row Button.agent-name {
|
|
95
317
|
width: 1fr;
|
|
96
|
-
height:
|
|
318
|
+
height: 1;
|
|
97
319
|
margin: 0;
|
|
98
320
|
padding: 0 1;
|
|
99
321
|
text-align: left;
|
|
@@ -101,28 +323,28 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
101
323
|
}
|
|
102
324
|
|
|
103
325
|
AgentsPanel .agent-row Button.agent-create {
|
|
104
|
-
width:
|
|
105
|
-
min-width:
|
|
106
|
-
height:
|
|
107
|
-
margin-left:
|
|
108
|
-
padding: 0
|
|
326
|
+
width: 3;
|
|
327
|
+
min-width: 3;
|
|
328
|
+
height: 1;
|
|
329
|
+
margin-left: 0;
|
|
330
|
+
padding: 0;
|
|
109
331
|
content-align: center middle;
|
|
110
332
|
}
|
|
111
333
|
|
|
112
334
|
AgentsPanel .agent-row Button.agent-delete {
|
|
113
335
|
width: 3;
|
|
114
336
|
min-width: 3;
|
|
115
|
-
height:
|
|
116
|
-
margin-left:
|
|
337
|
+
height: 1;
|
|
338
|
+
margin-left: 0;
|
|
117
339
|
padding: 0;
|
|
118
340
|
content-align: center middle;
|
|
119
341
|
}
|
|
120
342
|
|
|
121
343
|
AgentsPanel .agent-row Button.agent-share {
|
|
122
344
|
width: auto;
|
|
123
|
-
min-width:
|
|
124
|
-
height:
|
|
125
|
-
margin-left:
|
|
345
|
+
min-width: 6;
|
|
346
|
+
height: 1;
|
|
347
|
+
margin-left: 0;
|
|
126
348
|
padding: 0 1;
|
|
127
349
|
content-align: center middle;
|
|
128
350
|
}
|
|
@@ -136,25 +358,34 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
136
358
|
}
|
|
137
359
|
|
|
138
360
|
AgentsPanel .terminal-row {
|
|
139
|
-
height:
|
|
140
|
-
min-height: 2;
|
|
361
|
+
height: 1;
|
|
141
362
|
margin: 0;
|
|
142
363
|
}
|
|
143
364
|
|
|
144
365
|
AgentsPanel .terminal-row Button.terminal-switch {
|
|
145
366
|
width: 1fr;
|
|
146
|
-
height:
|
|
367
|
+
height: 1;
|
|
147
368
|
margin: 0;
|
|
148
369
|
padding: 0 1;
|
|
149
370
|
text-align: left;
|
|
150
|
-
content-align: left
|
|
371
|
+
content-align: left middle;
|
|
372
|
+
text-opacity: 60%;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
AgentsPanel .terminal-row Button.terminal-switch:hover {
|
|
376
|
+
text-opacity: 100%;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
AgentsPanel .terminal-row Button.active-terminal {
|
|
380
|
+
text-opacity: 100%;
|
|
381
|
+
text-style: bold;
|
|
151
382
|
}
|
|
152
383
|
|
|
153
384
|
AgentsPanel .terminal-row Button.terminal-close {
|
|
154
385
|
width: 3;
|
|
155
386
|
min-width: 3;
|
|
156
|
-
height:
|
|
157
|
-
margin
|
|
387
|
+
height: 1;
|
|
388
|
+
margin: 0;
|
|
158
389
|
padding: 0;
|
|
159
390
|
content-align: center middle;
|
|
160
391
|
}
|
|
@@ -164,28 +395,58 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
164
395
|
super().__init__()
|
|
165
396
|
self._refresh_lock = asyncio.Lock()
|
|
166
397
|
self._agents: list[dict] = []
|
|
398
|
+
self._active_terminal_id: str | None = None
|
|
167
399
|
self._rendered_fingerprint: str = ""
|
|
400
|
+
self._switch_worker: Optional[Worker] = None
|
|
401
|
+
self._switch_seq: int = 0
|
|
168
402
|
self._refresh_worker: Optional[Worker] = None
|
|
403
|
+
self._render_worker: Optional[Worker] = None
|
|
404
|
+
self._pending_render: bool = False
|
|
405
|
+
self._pending_render_reason: str | None = None
|
|
169
406
|
self._share_worker: Optional[Worker] = None
|
|
170
407
|
self._sync_worker: Optional[Worker] = None
|
|
171
408
|
self._share_agent_id: Optional[str] = None
|
|
172
409
|
self._sync_agent_id: Optional[str] = None
|
|
173
410
|
self._refresh_timer = None
|
|
411
|
+
self._spinner_timer = None
|
|
412
|
+
self._last_refresh_error_at: float | None = None
|
|
413
|
+
self._last_render_started_at: float | None = None
|
|
414
|
+
self._last_render_completed_at: float | None = None
|
|
415
|
+
self._process_snapshot: _ProcessSnapshot | None = None
|
|
174
416
|
|
|
175
417
|
def compose(self) -> ComposeResult:
|
|
176
418
|
with Horizontal(classes="summary"):
|
|
177
|
-
yield Button("
|
|
419
|
+
yield Button("Add Agent", id="create-agent", variant="primary")
|
|
178
420
|
with Vertical(id="agents-list", classes="list"):
|
|
179
|
-
yield Static("No agents yet. Click '
|
|
421
|
+
yield Static("No agents yet. Click 'Add Agent' to add one.")
|
|
422
|
+
|
|
423
|
+
def on_mount(self) -> None:
|
|
424
|
+
btn = self.query_one("#create-agent", Button)
|
|
425
|
+
btn.styles.border = ("round", "black")
|
|
426
|
+
btn.styles.text_align = "center"
|
|
427
|
+
btn.styles.content_align = ("center", "middle")
|
|
180
428
|
|
|
181
429
|
def on_show(self) -> None:
|
|
182
430
|
if self._refresh_timer is None:
|
|
183
|
-
|
|
431
|
+
# Refresh frequently, but avoid hammering SQLite/tmux (can cause transient empty UI).
|
|
432
|
+
self._refresh_timer = self.set_interval(
|
|
433
|
+
self.REFRESH_INTERVAL_SECONDS, self._on_refresh_timer
|
|
434
|
+
)
|
|
184
435
|
else:
|
|
185
436
|
try:
|
|
186
437
|
self._refresh_timer.resume()
|
|
187
438
|
except Exception:
|
|
188
439
|
pass
|
|
440
|
+
|
|
441
|
+
if self._spinner_timer is None:
|
|
442
|
+
self._spinner_timer = self.set_interval(
|
|
443
|
+
self.SPINNER_INTERVAL_SECONDS, self._on_spinner_timer
|
|
444
|
+
)
|
|
445
|
+
else:
|
|
446
|
+
try:
|
|
447
|
+
self._spinner_timer.resume()
|
|
448
|
+
except Exception:
|
|
449
|
+
pass
|
|
189
450
|
self.refresh_data()
|
|
190
451
|
|
|
191
452
|
def on_hide(self) -> None:
|
|
@@ -194,10 +455,24 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
194
455
|
self._refresh_timer.pause()
|
|
195
456
|
except Exception:
|
|
196
457
|
pass
|
|
458
|
+
if self._spinner_timer is not None:
|
|
459
|
+
try:
|
|
460
|
+
self._spinner_timer.pause()
|
|
461
|
+
except Exception:
|
|
462
|
+
pass
|
|
197
463
|
|
|
198
464
|
def _on_refresh_timer(self) -> None:
|
|
199
465
|
self.refresh_data()
|
|
200
466
|
|
|
467
|
+
def _on_spinner_timer(self) -> None:
|
|
468
|
+
if not self.display:
|
|
469
|
+
return
|
|
470
|
+
try:
|
|
471
|
+
for btn in self.query("Button.spinner"):
|
|
472
|
+
btn.refresh()
|
|
473
|
+
except Exception:
|
|
474
|
+
pass
|
|
475
|
+
|
|
201
476
|
def refresh_data(self) -> None:
|
|
202
477
|
if not self.display:
|
|
203
478
|
return
|
|
@@ -210,102 +485,350 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
210
485
|
self._collect_agents, thread=True, exit_on_error=False
|
|
211
486
|
)
|
|
212
487
|
|
|
213
|
-
def
|
|
214
|
-
"""
|
|
215
|
-
|
|
488
|
+
def diagnostics_state(self) -> dict[str, object]:
|
|
489
|
+
"""Small, safe snapshot for watchdog logging (never raises)."""
|
|
490
|
+
state: dict[str, object] = {}
|
|
216
491
|
try:
|
|
217
|
-
|
|
492
|
+
state["agents_count"] = int(len(self._agents))
|
|
493
|
+
state["rendered_fingerprint_len"] = int(len(self._rendered_fingerprint))
|
|
494
|
+
state["refresh_worker_state"] = str(
|
|
495
|
+
getattr(getattr(self, "_refresh_worker", None), "state", None)
|
|
496
|
+
)
|
|
497
|
+
state["render_worker_state"] = str(
|
|
498
|
+
getattr(getattr(self, "_render_worker", None), "state", None)
|
|
499
|
+
)
|
|
500
|
+
state["pending_render"] = bool(self._pending_render)
|
|
501
|
+
state["last_refresh_error_at"] = self._last_refresh_error_at
|
|
502
|
+
state["last_render_started_at"] = self._last_render_started_at
|
|
503
|
+
state["last_render_completed_at"] = self._last_render_completed_at
|
|
504
|
+
except Exception:
|
|
505
|
+
pass
|
|
506
|
+
try:
|
|
507
|
+
container = self.query_one("#agents-list", Vertical)
|
|
508
|
+
state["agents_list_children"] = int(len(getattr(container, "children", []) or []))
|
|
509
|
+
except Exception:
|
|
510
|
+
pass
|
|
511
|
+
return state
|
|
218
512
|
|
|
219
|
-
|
|
220
|
-
|
|
513
|
+
def _ui_blank(self) -> bool:
|
|
514
|
+
try:
|
|
515
|
+
container = self.query_one("#agents-list", Vertical)
|
|
516
|
+
return len(container.children) == 0
|
|
517
|
+
except Exception:
|
|
518
|
+
return False
|
|
519
|
+
|
|
520
|
+
def _schedule_render(self, *, reason: str) -> None:
|
|
521
|
+
if not self.display:
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
if self._render_worker is not None and self._render_worker.state in (
|
|
525
|
+
WorkerState.PENDING,
|
|
526
|
+
WorkerState.RUNNING,
|
|
527
|
+
):
|
|
528
|
+
self._pending_render = True
|
|
529
|
+
self._pending_render_reason = reason
|
|
530
|
+
return
|
|
531
|
+
|
|
532
|
+
self._pending_render = False
|
|
533
|
+
self._pending_render_reason = None
|
|
534
|
+
self._last_render_started_at = time.monotonic()
|
|
535
|
+
self._render_worker = self.run_worker(
|
|
536
|
+
self._render_agents(), group="agents-render", exit_on_error=False
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
def force_render(self, *, reason: str) -> None:
|
|
540
|
+
"""Force a re-render even if the collected data didn't change."""
|
|
541
|
+
# Reset fingerprint so the next refresh doesn't treat the UI as "up to date".
|
|
542
|
+
self._rendered_fingerprint = ""
|
|
543
|
+
if self._agents:
|
|
544
|
+
self._schedule_render(reason=reason)
|
|
545
|
+
|
|
546
|
+
@staticmethod
|
|
547
|
+
def _detect_running_agent(
|
|
548
|
+
pane_pid: int | None, snapshot: _ProcessSnapshot | None
|
|
549
|
+
) -> str | None:
|
|
550
|
+
"""Detect whether a known agent process exists under a tmux pane PID.
|
|
551
|
+
|
|
552
|
+
Uses a cached process snapshot built once per refresh to avoid spawning `ps`
|
|
553
|
+
repeatedly for every terminal row.
|
|
554
|
+
"""
|
|
555
|
+
if not pane_pid or snapshot is None:
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
children = snapshot.children
|
|
559
|
+
comms = snapshot.comms
|
|
560
|
+
stack = [pane_pid]
|
|
561
|
+
seen: set[int] = set()
|
|
562
|
+
while stack:
|
|
563
|
+
p = stack.pop()
|
|
564
|
+
if p in seen:
|
|
565
|
+
continue
|
|
566
|
+
seen.add(p)
|
|
567
|
+
comm = (comms.get(p) or "").lower()
|
|
568
|
+
for kw in ("claude", "codex", "opencode"):
|
|
569
|
+
if kw in comm:
|
|
570
|
+
return kw
|
|
571
|
+
stack.extend(children.get(p, []))
|
|
572
|
+
return None
|
|
573
|
+
|
|
574
|
+
@staticmethod
|
|
575
|
+
def _normalize_tty(tty: str) -> str:
|
|
576
|
+
s = (tty or "").strip()
|
|
577
|
+
if not s:
|
|
578
|
+
return ""
|
|
579
|
+
if s.startswith("/dev/"):
|
|
580
|
+
s = s[len("/dev/") :]
|
|
581
|
+
return s.strip()
|
|
582
|
+
|
|
583
|
+
def _get_process_snapshot(self, *, pane_ttys: set[str]) -> _ProcessSnapshot | None:
|
|
584
|
+
"""Return a cached process snapshot scoped to the given TTY set.
|
|
585
|
+
|
|
586
|
+
TTL is aligned with the panel refresh interval so the snapshot is at most one refresh stale.
|
|
587
|
+
"""
|
|
588
|
+
ttys = sorted(
|
|
589
|
+
{self._normalize_tty(t) for t in (pane_ttys or set()) if self._normalize_tty(t)}
|
|
590
|
+
)
|
|
591
|
+
if not ttys:
|
|
592
|
+
return None
|
|
593
|
+
tty_key = ",".join(ttys)
|
|
594
|
+
|
|
595
|
+
now = time.monotonic()
|
|
596
|
+
cached = self._process_snapshot
|
|
597
|
+
if (
|
|
598
|
+
cached
|
|
599
|
+
and cached.tty_key == tty_key
|
|
600
|
+
and (now - cached.created_at_monotonic) < float(self.REFRESH_INTERVAL_SECONDS)
|
|
601
|
+
):
|
|
602
|
+
return cached
|
|
603
|
+
|
|
604
|
+
try:
|
|
605
|
+
import subprocess
|
|
606
|
+
|
|
607
|
+
# Prefer TTY-limited `ps` to keep output small. BSD ps supports comma-separated TTY list.
|
|
608
|
+
result = subprocess.run(
|
|
609
|
+
["ps", "-o", "pid=,ppid=,comm=", "-t", tty_key],
|
|
610
|
+
capture_output=True,
|
|
611
|
+
text=True,
|
|
612
|
+
timeout=2,
|
|
613
|
+
)
|
|
614
|
+
stdout = result.stdout or ""
|
|
615
|
+
if result.returncode != 0 or not stdout.strip():
|
|
616
|
+
# Fallback: some environments may not accept `-t` list; fall back to a single
|
|
617
|
+
# snapshot of all processes and filter by TTY.
|
|
618
|
+
result = subprocess.run(
|
|
619
|
+
["ps", "-axo", "pid=,ppid=,tty=,comm="],
|
|
620
|
+
capture_output=True,
|
|
621
|
+
text=True,
|
|
622
|
+
timeout=2,
|
|
623
|
+
)
|
|
624
|
+
stdout = result.stdout or ""
|
|
625
|
+
|
|
626
|
+
allowed = set(ttys)
|
|
627
|
+
filtered_lines: list[str] = []
|
|
628
|
+
for line in stdout.splitlines():
|
|
629
|
+
parts = line.split(None, 3)
|
|
630
|
+
if len(parts) < 4:
|
|
631
|
+
continue
|
|
632
|
+
tty = self._normalize_tty(parts[2])
|
|
633
|
+
if tty and tty in allowed:
|
|
634
|
+
filtered_lines.append(f"{parts[0]} {parts[1]} {parts[3]}")
|
|
635
|
+
stdout = "\n".join(filtered_lines)
|
|
636
|
+
|
|
637
|
+
children: dict[int, list[int]] = {}
|
|
638
|
+
comms: dict[int, str] = {}
|
|
639
|
+
for line in stdout.splitlines():
|
|
640
|
+
parts = line.split(None, 2)
|
|
641
|
+
if len(parts) < 3:
|
|
642
|
+
continue
|
|
643
|
+
try:
|
|
644
|
+
pid, ppid = int(parts[0]), int(parts[1])
|
|
645
|
+
except ValueError:
|
|
646
|
+
continue
|
|
647
|
+
children.setdefault(ppid, []).append(pid)
|
|
648
|
+
comms[pid] = parts[2]
|
|
649
|
+
|
|
650
|
+
snap = _ProcessSnapshot(
|
|
651
|
+
created_at_monotonic=now, tty_key=tty_key, children=children, comms=comms
|
|
652
|
+
)
|
|
653
|
+
self._process_snapshot = snap
|
|
654
|
+
return snap
|
|
655
|
+
except Exception:
|
|
656
|
+
return None
|
|
657
|
+
|
|
658
|
+
def _collect_agents(self) -> dict:
|
|
659
|
+
"""Collect agent info with their terminals."""
|
|
660
|
+
agents: list[dict] = []
|
|
661
|
+
|
|
662
|
+
from ...db import get_database
|
|
663
|
+
|
|
664
|
+
# Dashboard should prefer correctness/stability over ultra-low lock timeouts.
|
|
665
|
+
db = get_database(read_only=True, connect_timeout_seconds=2.0)
|
|
666
|
+
|
|
667
|
+
# Critical: if this fails, let it surface as a worker ERROR so we can keep
|
|
668
|
+
# the last rendered UI instead of flashing an empty agent list.
|
|
669
|
+
agent_infos = db.list_agent_info()
|
|
670
|
+
|
|
671
|
+
# Best-effort: missing pieces should degrade gracefully (names still render).
|
|
672
|
+
try:
|
|
221
673
|
active_terminals = db.list_agents(status="active", limit=1000)
|
|
674
|
+
except Exception as e:
|
|
675
|
+
logger.debug(f"Failed to list active terminals: {e}")
|
|
676
|
+
active_terminals = []
|
|
222
677
|
|
|
223
|
-
|
|
678
|
+
try:
|
|
224
679
|
latest_links = db.list_latest_window_links(limit=2000)
|
|
225
|
-
|
|
680
|
+
except Exception:
|
|
681
|
+
latest_links = []
|
|
682
|
+
link_by_terminal = {
|
|
683
|
+
l.terminal_id: l for l in latest_links if getattr(l, "terminal_id", None)
|
|
684
|
+
}
|
|
226
685
|
|
|
227
|
-
|
|
686
|
+
tmux_windows_ok = True
|
|
687
|
+
try:
|
|
228
688
|
tmux_windows = tmux_manager.list_inner_windows()
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
689
|
+
except Exception as e:
|
|
690
|
+
logger.debug(f"Failed to list tmux windows: {e}")
|
|
691
|
+
tmux_windows_ok = False
|
|
692
|
+
tmux_windows = []
|
|
693
|
+
terminal_to_window = {
|
|
694
|
+
w.terminal_id: w for w in tmux_windows if getattr(w, "terminal_id", None)
|
|
695
|
+
}
|
|
232
696
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
697
|
+
# If we are running inside the managed tmux dashboard environment, only show terminals
|
|
698
|
+
# that still exist in the inner tmux server. This prevents stale buttons when a user
|
|
699
|
+
# exits a shell directly (tmux window disappears but DB record may still be "active").
|
|
700
|
+
try:
|
|
701
|
+
if tmux_manager.managed_env_enabled() and tmux_windows_ok:
|
|
702
|
+
present = {tid for tid in terminal_to_window.keys() if tid}
|
|
703
|
+
if present:
|
|
704
|
+
active_terminals = [t for t in active_terminals if getattr(t, "id", None) in present]
|
|
705
|
+
else:
|
|
706
|
+
# Inner session exists but no tracked terminals are present.
|
|
707
|
+
active_terminals = []
|
|
708
|
+
except Exception:
|
|
709
|
+
pass
|
|
710
|
+
|
|
711
|
+
pane_ttys: set[str] = set()
|
|
712
|
+
for w in tmux_windows:
|
|
713
|
+
if getattr(w, "pane_pid", None) and getattr(w, "pane_tty", None):
|
|
714
|
+
pane_ttys.add(str(w.pane_tty))
|
|
715
|
+
ps_snapshot = self._get_process_snapshot(pane_ttys=pane_ttys) if pane_ttys else None
|
|
716
|
+
|
|
717
|
+
# Detect currently active terminal
|
|
718
|
+
active_window = next((w for w in tmux_windows if w.active), None)
|
|
719
|
+
active_terminal_id = active_window.terminal_id if active_window else None
|
|
720
|
+
|
|
721
|
+
# Collect all session_ids for title lookup
|
|
722
|
+
session_ids: list[str] = []
|
|
723
|
+
for t in active_terminals:
|
|
724
|
+
link = link_by_terminal.get(t.id)
|
|
725
|
+
if link and getattr(link, "session_id", None):
|
|
726
|
+
session_ids.append(link.session_id)
|
|
727
|
+
continue
|
|
728
|
+
window = terminal_to_window.get(t.id)
|
|
729
|
+
if window and getattr(window, "session_id", None):
|
|
730
|
+
session_ids.append(window.session_id)
|
|
731
|
+
continue
|
|
732
|
+
# Fallback: agent record (works even when WindowLink isn't available yet / at all).
|
|
733
|
+
agent_session_id = getattr(t, "session_id", None)
|
|
734
|
+
if agent_session_id:
|
|
735
|
+
session_ids.append(agent_session_id)
|
|
736
|
+
|
|
737
|
+
titles: dict[str, str] = {}
|
|
738
|
+
session_agent_id: dict[str, str] = {}
|
|
739
|
+
try:
|
|
740
|
+
uniq_session_ids = sorted({sid for sid in session_ids if sid})
|
|
741
|
+
if uniq_session_ids:
|
|
742
|
+
sessions = db.get_sessions_by_ids(uniq_session_ids)
|
|
743
|
+
for s in sessions:
|
|
744
|
+
sid = getattr(s, "id", None)
|
|
745
|
+
if not sid:
|
|
746
|
+
continue
|
|
747
|
+
title = (getattr(s, "session_title", "") or "").strip()
|
|
748
|
+
if title:
|
|
749
|
+
titles[sid] = title
|
|
750
|
+
agent_id = getattr(s, "agent_id", None)
|
|
751
|
+
if agent_id:
|
|
752
|
+
session_agent_id[sid] = agent_id
|
|
753
|
+
except Exception:
|
|
754
|
+
titles = {}
|
|
755
|
+
session_agent_id = {}
|
|
756
|
+
|
|
757
|
+
# Map agent_info.id -> list of terminals
|
|
758
|
+
agent_to_terminals: dict[str, list[dict]] = {}
|
|
759
|
+
for t in active_terminals:
|
|
760
|
+
# Find which agent_info this terminal belongs to
|
|
761
|
+
agent_info_id = None
|
|
762
|
+
|
|
763
|
+
link = link_by_terminal.get(t.id)
|
|
764
|
+
|
|
765
|
+
# Method 1: Check source field for "agent:{agent_info_id}" format
|
|
766
|
+
source = t.source or ""
|
|
767
|
+
if source.startswith("agent:"):
|
|
768
|
+
agent_info_id = source[6:]
|
|
769
|
+
|
|
770
|
+
# Method 2: WindowLink agent_id
|
|
771
|
+
if not agent_info_id and link and getattr(link, "agent_id", None):
|
|
772
|
+
agent_info_id = link.agent_id
|
|
773
|
+
|
|
774
|
+
# Method 3: Fallback - check tmux window's session.agent_id
|
|
775
|
+
if not agent_info_id:
|
|
240
776
|
window = terminal_to_window.get(t.id)
|
|
241
|
-
if window and window
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if source.startswith("agent:"):
|
|
257
|
-
agent_info_id = source[6:]
|
|
258
|
-
|
|
259
|
-
# Method 2: WindowLink agent_id
|
|
260
|
-
if not agent_info_id and link and link.agent_id:
|
|
261
|
-
agent_info_id = link.agent_id
|
|
262
|
-
|
|
263
|
-
# Method 3: Fallback - check tmux window's session.agent_id
|
|
264
|
-
if not agent_info_id:
|
|
265
|
-
window = terminal_to_window.get(t.id)
|
|
266
|
-
if window and window.session_id:
|
|
267
|
-
session = db.get_session_by_id(window.session_id)
|
|
268
|
-
if session:
|
|
269
|
-
agent_info_id = session.agent_id
|
|
270
|
-
|
|
271
|
-
if agent_info_id:
|
|
272
|
-
if agent_info_id not in agent_to_terminals:
|
|
273
|
-
agent_to_terminals[agent_info_id] = []
|
|
274
|
-
|
|
275
|
-
# Get session_id from windowlink (preferred) or tmux window
|
|
276
|
-
window = terminal_to_window.get(t.id)
|
|
277
|
-
session_id = (
|
|
278
|
-
link.session_id if link and link.session_id else (window.session_id if window else None)
|
|
279
|
-
)
|
|
280
|
-
title = titles.get(session_id, "") if session_id else ""
|
|
281
|
-
|
|
282
|
-
agent_to_terminals[agent_info_id].append(
|
|
283
|
-
{
|
|
284
|
-
"terminal_id": t.id,
|
|
285
|
-
"session_id": session_id,
|
|
286
|
-
"provider": link.provider if link and link.provider else (t.provider or ""),
|
|
287
|
-
"session_type": t.session_type or "",
|
|
288
|
-
"title": title,
|
|
289
|
-
"cwd": t.cwd or "",
|
|
290
|
-
}
|
|
777
|
+
if window and getattr(window, "session_id", None):
|
|
778
|
+
agent_info_id = session_agent_id.get(window.session_id) or None
|
|
779
|
+
|
|
780
|
+
if agent_info_id:
|
|
781
|
+
agent_to_terminals.setdefault(agent_info_id, [])
|
|
782
|
+
|
|
783
|
+
# Get session_id from windowlink (preferred) or tmux window
|
|
784
|
+
window = terminal_to_window.get(t.id)
|
|
785
|
+
session_id = (
|
|
786
|
+
link.session_id
|
|
787
|
+
if link and getattr(link, "session_id", None)
|
|
788
|
+
else (
|
|
789
|
+
window.session_id
|
|
790
|
+
if window and getattr(window, "session_id", None)
|
|
791
|
+
else getattr(t, "session_id", None)
|
|
291
792
|
)
|
|
793
|
+
)
|
|
794
|
+
title = titles.get(session_id, "") if session_id else ""
|
|
292
795
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
"name": info.name,
|
|
299
|
-
"description": info.description or "",
|
|
300
|
-
"terminals": terminals,
|
|
301
|
-
"share_url": getattr(info, "share_url", None),
|
|
302
|
-
"last_synced_at": getattr(info, "last_synced_at", None),
|
|
303
|
-
}
|
|
796
|
+
pane_cmd = window.pane_current_command if window else None
|
|
797
|
+
provider = (
|
|
798
|
+
link.provider
|
|
799
|
+
if link and getattr(link, "provider", None)
|
|
800
|
+
else (t.provider or "")
|
|
304
801
|
)
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
802
|
+
pane_pid = window.pane_pid if window else None
|
|
803
|
+
running_agent = self._detect_running_agent(pane_pid, ps_snapshot)
|
|
804
|
+
|
|
805
|
+
entry = {
|
|
806
|
+
"terminal_id": t.id,
|
|
807
|
+
"window_id": window.window_id if window else None,
|
|
808
|
+
"session_id": session_id,
|
|
809
|
+
"provider": provider,
|
|
810
|
+
"session_type": t.session_type or "",
|
|
811
|
+
"title": title,
|
|
812
|
+
"cwd": t.cwd or "",
|
|
813
|
+
"running_agent": running_agent,
|
|
814
|
+
"pane_current_command": pane_cmd,
|
|
815
|
+
}
|
|
816
|
+
agent_to_terminals[agent_info_id].append(entry)
|
|
817
|
+
|
|
818
|
+
for info in agent_infos:
|
|
819
|
+
terminals = agent_to_terminals.get(info.id, [])
|
|
820
|
+
agents.append(
|
|
821
|
+
{
|
|
822
|
+
"id": info.id,
|
|
823
|
+
"name": info.name,
|
|
824
|
+
"description": info.description or "",
|
|
825
|
+
"terminals": terminals,
|
|
826
|
+
"share_url": getattr(info, "share_url", None),
|
|
827
|
+
"last_synced_at": getattr(info, "last_synced_at", None),
|
|
828
|
+
}
|
|
829
|
+
)
|
|
308
830
|
|
|
831
|
+
return {"agents": agents, "active_terminal_id": active_terminal_id}
|
|
309
832
|
|
|
310
833
|
@staticmethod
|
|
311
834
|
def _fingerprint(agents: list[dict]) -> str:
|
|
@@ -316,21 +839,122 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
316
839
|
return ""
|
|
317
840
|
|
|
318
841
|
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
842
|
+
# Handle switch worker
|
|
843
|
+
if self._switch_worker is not None and event.worker is self._switch_worker:
|
|
844
|
+
if event.state == WorkerState.SUCCESS:
|
|
845
|
+
result = self._switch_worker.result or {}
|
|
846
|
+
if isinstance(result, dict):
|
|
847
|
+
seq = int(result.get("seq", 0) or 0)
|
|
848
|
+
if seq != self._switch_seq:
|
|
849
|
+
return
|
|
850
|
+
terminal_id = str(result.get("terminal_id", "") or "").strip() or None
|
|
851
|
+
ok = bool(result.get("ok", False))
|
|
852
|
+
if ok and terminal_id:
|
|
853
|
+
self._active_terminal_id = terminal_id
|
|
854
|
+
self._update_active_terminal_ui(terminal_id)
|
|
855
|
+
# Prevent the next refresh from forcing a full re-render just due to
|
|
856
|
+
# active-terminal changes.
|
|
857
|
+
self._rendered_fingerprint = (
|
|
858
|
+
self._fingerprint(self._agents) + f"|active:{self._active_terminal_id}"
|
|
859
|
+
)
|
|
860
|
+
else:
|
|
861
|
+
msg = str(result.get("error", "") or "Failed to switch").strip()
|
|
862
|
+
self.app.notify(msg, title="Agent", severity="error")
|
|
863
|
+
self.refresh_data()
|
|
864
|
+
elif event.state == WorkerState.ERROR:
|
|
865
|
+
err = self._switch_worker.error
|
|
866
|
+
msg = "Failed to switch"
|
|
867
|
+
if isinstance(err, BaseException):
|
|
868
|
+
msg = f"Failed to switch: {err}"
|
|
869
|
+
elif err:
|
|
870
|
+
msg = f"Failed to switch: {err}"
|
|
871
|
+
self.app.notify(msg, title="Agent", severity="error")
|
|
872
|
+
self.refresh_data()
|
|
873
|
+
|
|
874
|
+
if event.state in {WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED}:
|
|
875
|
+
self._switch_worker = None
|
|
876
|
+
return
|
|
877
|
+
|
|
319
878
|
# Handle refresh worker
|
|
320
879
|
if self._refresh_worker is not None and event.worker is self._refresh_worker:
|
|
321
880
|
if event.state == WorkerState.ERROR:
|
|
322
|
-
|
|
881
|
+
# Keep the last successfully-rendered list on refresh errors to avoid
|
|
882
|
+
# flashing an empty Agents tab during transient tmux/SQLite hiccups.
|
|
883
|
+
self._last_refresh_error_at = time.monotonic()
|
|
884
|
+
err = self._refresh_worker.error
|
|
885
|
+
if isinstance(err, BaseException):
|
|
886
|
+
logger.warning(
|
|
887
|
+
"Agents refresh failed",
|
|
888
|
+
exc_info=(type(err), err, err.__traceback__),
|
|
889
|
+
)
|
|
890
|
+
else:
|
|
891
|
+
logger.warning(f"Agents refresh failed: {err}")
|
|
892
|
+
return
|
|
323
893
|
elif event.state == WorkerState.SUCCESS:
|
|
324
|
-
|
|
894
|
+
prev_agents = self._agents
|
|
895
|
+
prev_active = self._active_terminal_id
|
|
896
|
+
payload = self._refresh_worker.result or {}
|
|
897
|
+
if isinstance(payload, dict) and "agents" in payload:
|
|
898
|
+
self._agents = payload.get("agents") or []
|
|
899
|
+
self._active_terminal_id = payload.get("active_terminal_id") or None
|
|
900
|
+
else:
|
|
901
|
+
self._agents = payload or []
|
|
902
|
+
self._last_refresh_error_at = None
|
|
325
903
|
else:
|
|
326
904
|
return
|
|
327
|
-
|
|
328
|
-
|
|
905
|
+
|
|
906
|
+
# Fast path: only the active terminal changed; update button classes in-place
|
|
907
|
+
# to avoid full re-render flicker / input lag.
|
|
908
|
+
try:
|
|
909
|
+
prev_agents_fp = self._fingerprint(prev_agents)
|
|
910
|
+
new_agents_fp = self._fingerprint(self._agents)
|
|
911
|
+
if (
|
|
912
|
+
prev_agents_fp == new_agents_fp
|
|
913
|
+
and prev_active != self._active_terminal_id
|
|
914
|
+
and not self._ui_blank()
|
|
915
|
+
):
|
|
916
|
+
self._update_active_terminal_ui(self._active_terminal_id)
|
|
917
|
+
self._rendered_fingerprint = new_agents_fp + f"|active:{self._active_terminal_id}"
|
|
918
|
+
return
|
|
919
|
+
except Exception:
|
|
920
|
+
new_agents_fp = self._fingerprint(self._agents)
|
|
921
|
+
|
|
922
|
+
fp = new_agents_fp + f"|active:{self._active_terminal_id}"
|
|
923
|
+
# Important: the UI can become blank due to cancellation or transient Textual issues.
|
|
924
|
+
# If data didn't change but the widget tree is empty, force a re-render.
|
|
925
|
+
should_render = (fp != self._rendered_fingerprint) or (
|
|
926
|
+
self._agents and self._ui_blank()
|
|
927
|
+
)
|
|
928
|
+
if not should_render:
|
|
329
929
|
return # nothing changed – skip re-render to avoid flicker
|
|
330
930
|
self._rendered_fingerprint = fp
|
|
331
|
-
self.
|
|
332
|
-
|
|
333
|
-
|
|
931
|
+
self._schedule_render(reason="refresh")
|
|
932
|
+
return
|
|
933
|
+
|
|
934
|
+
# Handle render worker
|
|
935
|
+
if self._render_worker is not None and event.worker is self._render_worker:
|
|
936
|
+
if event.state == WorkerState.ERROR:
|
|
937
|
+
err = self._render_worker.error
|
|
938
|
+
if isinstance(err, BaseException):
|
|
939
|
+
logger.warning(
|
|
940
|
+
"Agents render failed",
|
|
941
|
+
exc_info=(type(err), err, err.__traceback__),
|
|
942
|
+
)
|
|
943
|
+
else:
|
|
944
|
+
logger.warning(f"Agents render failed: {err}")
|
|
945
|
+
# Force next refresh to re-render even if data is unchanged.
|
|
946
|
+
self._rendered_fingerprint = ""
|
|
947
|
+
elif event.state == WorkerState.SUCCESS:
|
|
948
|
+
self._last_render_completed_at = time.monotonic()
|
|
949
|
+
elif event.state == WorkerState.CANCELLED:
|
|
950
|
+
self._rendered_fingerprint = ""
|
|
951
|
+
|
|
952
|
+
if event.state in {WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED}:
|
|
953
|
+
self._last_render_completed_at = time.monotonic()
|
|
954
|
+
self._render_worker = None
|
|
955
|
+
if self._pending_render:
|
|
956
|
+
reason = self._pending_render_reason or "pending"
|
|
957
|
+
self._schedule_render(reason=reason)
|
|
334
958
|
return
|
|
335
959
|
|
|
336
960
|
# Handle share worker
|
|
@@ -345,146 +969,36 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
345
969
|
async with self._refresh_lock:
|
|
346
970
|
try:
|
|
347
971
|
container = self.query_one("#agents-list", Vertical)
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
await container.remove_children()
|
|
352
|
-
|
|
353
|
-
if not self._agents:
|
|
354
|
-
await container.mount(
|
|
355
|
-
Static("No agents yet. Click 'Create Agent' to add one.")
|
|
356
|
-
)
|
|
357
|
-
return
|
|
972
|
+
with self.app.batch_update():
|
|
973
|
+
await container.remove_children()
|
|
358
974
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
# Agent row with name, create button, and delete button
|
|
363
|
-
row = Horizontal(classes="agent-row")
|
|
364
|
-
await container.mount(row)
|
|
365
|
-
|
|
366
|
-
# Agent name button
|
|
367
|
-
name_label = Text(agent["name"], style="bold")
|
|
368
|
-
terminal_count = len(agent["terminals"])
|
|
369
|
-
if terminal_count > 0:
|
|
370
|
-
name_label.append(f" ({terminal_count})", style="dim")
|
|
371
|
-
|
|
372
|
-
await row.mount(
|
|
373
|
-
AgentNameButton(
|
|
374
|
-
name_label,
|
|
375
|
-
id=f"agent-{safe_id}",
|
|
376
|
-
name=agent["id"],
|
|
377
|
-
classes="agent-name",
|
|
378
|
-
)
|
|
379
|
-
)
|
|
380
|
-
|
|
381
|
-
# Share or Sync button (Sync if agent already has a share_url)
|
|
382
|
-
if agent.get("share_url"):
|
|
383
|
-
await row.mount(
|
|
384
|
-
Button(
|
|
385
|
-
"Sync",
|
|
386
|
-
id=f"sync-{safe_id}",
|
|
387
|
-
name=agent["id"],
|
|
388
|
-
classes="agent-share",
|
|
975
|
+
if not self._agents:
|
|
976
|
+
await container.mount(
|
|
977
|
+
Static("No agents yet. Click 'Add Agent' to add one.")
|
|
389
978
|
)
|
|
390
|
-
|
|
391
|
-
# Link button to copy share URL to clipboard
|
|
392
|
-
await row.mount(
|
|
393
|
-
Button(
|
|
394
|
-
"Link",
|
|
395
|
-
id=f"link-{safe_id}",
|
|
396
|
-
name=agent["id"],
|
|
397
|
-
classes="agent-share",
|
|
398
|
-
)
|
|
399
|
-
)
|
|
400
|
-
else:
|
|
401
|
-
await row.mount(
|
|
402
|
-
Button(
|
|
403
|
-
"Share",
|
|
404
|
-
id=f"share-{safe_id}",
|
|
405
|
-
name=agent["id"],
|
|
406
|
-
classes="agent-share",
|
|
407
|
-
)
|
|
408
|
-
)
|
|
979
|
+
return
|
|
409
980
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
Button(
|
|
423
|
-
"✕",
|
|
424
|
-
id=f"delete-{safe_id}",
|
|
425
|
-
name=agent["id"],
|
|
426
|
-
variant="error",
|
|
427
|
-
classes="agent-delete",
|
|
981
|
+
blocks = [
|
|
982
|
+
_AgentBlockWidget(agent=agent, active_terminal_id=self._active_terminal_id)
|
|
983
|
+
for agent in self._agents
|
|
984
|
+
]
|
|
985
|
+
await container.mount_all(blocks)
|
|
986
|
+
except Exception:
|
|
987
|
+
logger.exception("Failed to render agents list")
|
|
988
|
+
try:
|
|
989
|
+
container = self.query_one("#agents-list", Vertical)
|
|
990
|
+
await container.remove_children()
|
|
991
|
+
await container.mount(
|
|
992
|
+
Static("Agents UI error (see ~/.aline/.logs/dashboard.log)")
|
|
428
993
|
)
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
term_safe_id = self._safe_id(term["terminal_id"])
|
|
438
|
-
term_row = Horizontal(classes="terminal-row")
|
|
439
|
-
await term_list.mount(term_row)
|
|
440
|
-
|
|
441
|
-
label = self._make_terminal_label(term)
|
|
442
|
-
await term_row.mount(
|
|
443
|
-
Button(
|
|
444
|
-
label,
|
|
445
|
-
id=f"switch-{term_safe_id}",
|
|
446
|
-
name=term["terminal_id"],
|
|
447
|
-
classes="terminal-switch",
|
|
448
|
-
)
|
|
449
|
-
)
|
|
450
|
-
await term_row.mount(
|
|
451
|
-
Button(
|
|
452
|
-
"✕",
|
|
453
|
-
id=f"close-{term_safe_id}",
|
|
454
|
-
name=term["terminal_id"],
|
|
455
|
-
variant="error",
|
|
456
|
-
classes="terminal-close",
|
|
457
|
-
)
|
|
458
|
-
)
|
|
459
|
-
|
|
460
|
-
def _make_terminal_label(self, term: dict) -> Text:
|
|
461
|
-
"""Generate label for a terminal."""
|
|
462
|
-
provider = term.get("provider", "")
|
|
463
|
-
session_id = term.get("session_id", "")
|
|
464
|
-
title = term.get("title", "")
|
|
465
|
-
|
|
466
|
-
label = Text(no_wrap=True, overflow="ellipsis")
|
|
467
|
-
|
|
468
|
-
# First line: title or provider
|
|
469
|
-
if title:
|
|
470
|
-
label.append(title)
|
|
471
|
-
elif provider:
|
|
472
|
-
label.append(provider.capitalize())
|
|
473
|
-
else:
|
|
474
|
-
label.append("Terminal")
|
|
475
|
-
|
|
476
|
-
label.append("\n")
|
|
477
|
-
|
|
478
|
-
# Second line: [provider] session_id
|
|
479
|
-
if provider:
|
|
480
|
-
detail = f"[{provider.capitalize()}]"
|
|
481
|
-
else:
|
|
482
|
-
detail = ""
|
|
483
|
-
if session_id:
|
|
484
|
-
detail = f"{detail} #{self._short_id(session_id)}"
|
|
485
|
-
|
|
486
|
-
label.append(detail, style="dim")
|
|
487
|
-
return label
|
|
994
|
+
except Exception:
|
|
995
|
+
pass
|
|
996
|
+
return
|
|
997
|
+
except asyncio.CancelledError:
|
|
998
|
+
# Best-effort: avoid leaving the UI in a permanently blank state.
|
|
999
|
+
logger.warning("Agents render cancelled")
|
|
1000
|
+
self._rendered_fingerprint = ""
|
|
1001
|
+
return
|
|
488
1002
|
|
|
489
1003
|
@staticmethod
|
|
490
1004
|
def _short_id(val: str | None) -> str:
|
|
@@ -532,6 +1046,21 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
532
1046
|
pass
|
|
533
1047
|
return None
|
|
534
1048
|
|
|
1049
|
+
def _update_active_terminal_ui(self, terminal_id: str | None) -> None:
|
|
1050
|
+
"""Update just the active-terminal button classes (no full re-render)."""
|
|
1051
|
+
try:
|
|
1052
|
+
for btn in self.query("Button.terminal-switch"):
|
|
1053
|
+
btn_id = btn.id or ""
|
|
1054
|
+
if not btn_id.startswith("switch-"):
|
|
1055
|
+
continue
|
|
1056
|
+
is_active = bool(terminal_id and (btn.name == terminal_id))
|
|
1057
|
+
if is_active:
|
|
1058
|
+
btn.add_class("active-terminal")
|
|
1059
|
+
else:
|
|
1060
|
+
btn.remove_class("active-terminal")
|
|
1061
|
+
except Exception:
|
|
1062
|
+
return
|
|
1063
|
+
|
|
535
1064
|
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
536
1065
|
btn_id = event.button.id or ""
|
|
537
1066
|
|
|
@@ -570,7 +1099,7 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
570
1099
|
|
|
571
1100
|
if btn_id.startswith("switch-"):
|
|
572
1101
|
terminal_id = event.button.name or ""
|
|
573
|
-
|
|
1102
|
+
self._request_switch_to_terminal(terminal_id)
|
|
574
1103
|
return
|
|
575
1104
|
|
|
576
1105
|
if btn_id.startswith("close-"):
|
|
@@ -613,9 +1142,7 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
613
1142
|
if result:
|
|
614
1143
|
if result.get("imported"):
|
|
615
1144
|
n = result.get("sessions_imported", 0)
|
|
616
|
-
self.app.notify(
|
|
617
|
-
f"Imported: {result.get('name')} ({n} sessions)", title="Agent"
|
|
618
|
-
)
|
|
1145
|
+
self.app.notify(f"Imported: {result.get('name')} ({n} sessions)", title="Agent")
|
|
619
1146
|
else:
|
|
620
1147
|
self.app.notify(f"Created: {result.get('name')}", title="Agent")
|
|
621
1148
|
self.refresh_data()
|
|
@@ -653,11 +1180,10 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
653
1180
|
|
|
654
1181
|
# Create the terminal with agent association
|
|
655
1182
|
self.run_worker(
|
|
656
|
-
self._do_create_terminal(
|
|
657
|
-
agent_type, workspace, skip_permissions, no_track, agent_id
|
|
658
|
-
),
|
|
1183
|
+
self._do_create_terminal(agent_type, workspace, skip_permissions, no_track, agent_id),
|
|
659
1184
|
group="terminal-create",
|
|
660
1185
|
exclusive=True,
|
|
1186
|
+
exit_on_error=False,
|
|
661
1187
|
)
|
|
662
1188
|
|
|
663
1189
|
async def _do_create_terminal(
|
|
@@ -670,9 +1196,7 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
670
1196
|
) -> None:
|
|
671
1197
|
"""Actually create the terminal with agent association."""
|
|
672
1198
|
if agent_type == "claude":
|
|
673
|
-
await self._create_claude_terminal(
|
|
674
|
-
workspace, skip_permissions, no_track, agent_id
|
|
675
|
-
)
|
|
1199
|
+
await self._create_claude_terminal(workspace, skip_permissions, no_track, agent_id)
|
|
676
1200
|
elif agent_type == "codex":
|
|
677
1201
|
await self._create_codex_terminal(workspace, no_track, agent_id)
|
|
678
1202
|
elif agent_type == "opencode":
|
|
@@ -688,6 +1212,16 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
688
1212
|
"""Create a Claude terminal associated with an agent."""
|
|
689
1213
|
terminal_id = tmux_manager.new_terminal_id()
|
|
690
1214
|
context_id = tmux_manager.new_context_id("cc")
|
|
1215
|
+
logger.info(
|
|
1216
|
+
"Create terminal requested: provider=claude terminal_id=%s agent_id=%s workspace=%s "
|
|
1217
|
+
"no_track=%s skip_permissions=%s context_id=%s",
|
|
1218
|
+
terminal_id,
|
|
1219
|
+
agent_id,
|
|
1220
|
+
workspace,
|
|
1221
|
+
no_track,
|
|
1222
|
+
skip_permissions,
|
|
1223
|
+
context_id,
|
|
1224
|
+
)
|
|
691
1225
|
|
|
692
1226
|
# Prepare CODEX_HOME so user can run codex in this terminal
|
|
693
1227
|
try:
|
|
@@ -731,6 +1265,13 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
731
1265
|
)
|
|
732
1266
|
|
|
733
1267
|
if created:
|
|
1268
|
+
logger.info(
|
|
1269
|
+
"Create terminal success: provider=claude terminal_id=%s window_id=%s",
|
|
1270
|
+
terminal_id,
|
|
1271
|
+
created.window_id,
|
|
1272
|
+
)
|
|
1273
|
+
if not tmux_manager.focus_right_pane():
|
|
1274
|
+
logger.warning("Create terminal: focus_right_pane failed (provider=claude)")
|
|
734
1275
|
# Store agent association in database with agent_info_id in source
|
|
735
1276
|
try:
|
|
736
1277
|
from ...db import get_database
|
|
@@ -748,13 +1289,10 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
748
1289
|
except Exception:
|
|
749
1290
|
pass
|
|
750
1291
|
else:
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
)
|
|
1292
|
+
logger.warning("Create terminal failed: provider=claude terminal_id=%s", terminal_id)
|
|
1293
|
+
self.app.notify("Failed to create terminal", title="Agent", severity="error")
|
|
754
1294
|
|
|
755
|
-
async def _create_codex_terminal(
|
|
756
|
-
self, workspace: str, no_track: bool, agent_id: str
|
|
757
|
-
) -> None:
|
|
1295
|
+
async def _create_codex_terminal(self, workspace: str, no_track: bool, agent_id: str) -> None:
|
|
758
1296
|
"""Create a Codex terminal associated with an agent."""
|
|
759
1297
|
try:
|
|
760
1298
|
from ...db import get_database
|
|
@@ -779,6 +1317,15 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
779
1317
|
|
|
780
1318
|
terminal_id = tmux_manager.new_terminal_id()
|
|
781
1319
|
context_id = tmux_manager.new_context_id("cx")
|
|
1320
|
+
logger.info(
|
|
1321
|
+
"Create terminal requested: provider=codex terminal_id=%s agent_id=%s workspace=%s "
|
|
1322
|
+
"no_track=%s context_id=%s",
|
|
1323
|
+
terminal_id,
|
|
1324
|
+
agent_id,
|
|
1325
|
+
workspace,
|
|
1326
|
+
no_track,
|
|
1327
|
+
context_id,
|
|
1328
|
+
)
|
|
782
1329
|
|
|
783
1330
|
try:
|
|
784
1331
|
from ...codex_home import prepare_codex_home
|
|
@@ -817,9 +1364,7 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
817
1364
|
except Exception:
|
|
818
1365
|
pass
|
|
819
1366
|
|
|
820
|
-
command = self._command_in_directory(
|
|
821
|
-
tmux_manager.zsh_run_and_keep_open("codex"), workspace
|
|
822
|
-
)
|
|
1367
|
+
command = self._command_in_directory(tmux_manager.zsh_run_and_keep_open("codex"), workspace)
|
|
823
1368
|
|
|
824
1369
|
created = tmux_manager.create_inner_window(
|
|
825
1370
|
"codex",
|
|
@@ -830,15 +1375,30 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
830
1375
|
no_track=no_track,
|
|
831
1376
|
)
|
|
832
1377
|
|
|
833
|
-
if
|
|
834
|
-
|
|
835
|
-
"
|
|
1378
|
+
if created:
|
|
1379
|
+
logger.info(
|
|
1380
|
+
"Create terminal success: provider=codex terminal_id=%s window_id=%s",
|
|
1381
|
+
terminal_id,
|
|
1382
|
+
created.window_id,
|
|
836
1383
|
)
|
|
1384
|
+
if not tmux_manager.focus_right_pane():
|
|
1385
|
+
logger.warning("Create terminal: focus_right_pane failed (provider=codex)")
|
|
1386
|
+
else:
|
|
1387
|
+
logger.warning("Create terminal failed: provider=codex terminal_id=%s", terminal_id)
|
|
1388
|
+
self.app.notify("Failed to create terminal", title="Agent", severity="error")
|
|
837
1389
|
|
|
838
1390
|
async def _create_opencode_terminal(self, workspace: str, agent_id: str) -> None:
|
|
839
1391
|
"""Create an Opencode terminal associated with an agent."""
|
|
840
1392
|
terminal_id = tmux_manager.new_terminal_id()
|
|
841
1393
|
context_id = tmux_manager.new_context_id("oc")
|
|
1394
|
+
logger.info(
|
|
1395
|
+
"Create terminal requested: provider=opencode terminal_id=%s agent_id=%s workspace=%s "
|
|
1396
|
+
"context_id=%s",
|
|
1397
|
+
terminal_id,
|
|
1398
|
+
agent_id,
|
|
1399
|
+
workspace,
|
|
1400
|
+
context_id,
|
|
1401
|
+
)
|
|
842
1402
|
|
|
843
1403
|
# Prepare CODEX_HOME so user can run codex in this terminal
|
|
844
1404
|
try:
|
|
@@ -875,6 +1435,13 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
875
1435
|
)
|
|
876
1436
|
|
|
877
1437
|
if created:
|
|
1438
|
+
logger.info(
|
|
1439
|
+
"Create terminal success: provider=opencode terminal_id=%s window_id=%s",
|
|
1440
|
+
terminal_id,
|
|
1441
|
+
created.window_id,
|
|
1442
|
+
)
|
|
1443
|
+
if not tmux_manager.focus_right_pane():
|
|
1444
|
+
logger.warning("Create terminal: focus_right_pane failed (provider=opencode)")
|
|
878
1445
|
# Store agent association in database
|
|
879
1446
|
try:
|
|
880
1447
|
from ...db import get_database
|
|
@@ -892,14 +1459,20 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
892
1459
|
except Exception:
|
|
893
1460
|
pass
|
|
894
1461
|
else:
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
)
|
|
1462
|
+
logger.warning("Create terminal failed: provider=opencode terminal_id=%s", terminal_id)
|
|
1463
|
+
self.app.notify("Failed to create terminal", title="Agent", severity="error")
|
|
898
1464
|
|
|
899
1465
|
async def _create_zsh_terminal(self, workspace: str, agent_id: str) -> None:
|
|
900
1466
|
"""Create a zsh terminal associated with an agent."""
|
|
901
1467
|
terminal_id = tmux_manager.new_terminal_id()
|
|
902
1468
|
context_id = tmux_manager.new_context_id("zsh")
|
|
1469
|
+
logger.info(
|
|
1470
|
+
"Create terminal requested: provider=zsh terminal_id=%s agent_id=%s workspace=%s context_id=%s",
|
|
1471
|
+
terminal_id,
|
|
1472
|
+
agent_id,
|
|
1473
|
+
workspace,
|
|
1474
|
+
context_id,
|
|
1475
|
+
)
|
|
903
1476
|
|
|
904
1477
|
# Prepare CODEX_HOME so user can run codex in this terminal
|
|
905
1478
|
try:
|
|
@@ -934,6 +1507,13 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
934
1507
|
)
|
|
935
1508
|
|
|
936
1509
|
if created:
|
|
1510
|
+
logger.info(
|
|
1511
|
+
"Create terminal success: provider=zsh terminal_id=%s window_id=%s",
|
|
1512
|
+
terminal_id,
|
|
1513
|
+
created.window_id,
|
|
1514
|
+
)
|
|
1515
|
+
if not tmux_manager.focus_right_pane():
|
|
1516
|
+
logger.warning("Create terminal: focus_right_pane failed (provider=zsh)")
|
|
937
1517
|
# Store agent association in database
|
|
938
1518
|
try:
|
|
939
1519
|
from ...db import get_database
|
|
@@ -951,9 +1531,8 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
951
1531
|
except Exception:
|
|
952
1532
|
pass
|
|
953
1533
|
else:
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
)
|
|
1534
|
+
logger.warning("Create terminal failed: provider=zsh terminal_id=%s", terminal_id)
|
|
1535
|
+
self.app.notify("Failed to create terminal", title="Agent", severity="error")
|
|
957
1536
|
|
|
958
1537
|
def _install_claude_hooks(self, workspace: str) -> None:
|
|
959
1538
|
"""Install Claude hooks for a workspace."""
|
|
@@ -980,12 +1559,8 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
980
1559
|
|
|
981
1560
|
project_root = Path(workspace)
|
|
982
1561
|
install_stop_hook(get_stop_settings_path(project_root), quiet=True)
|
|
983
|
-
install_user_prompt_submit_hook(
|
|
984
|
-
|
|
985
|
-
)
|
|
986
|
-
install_permission_request_hook(
|
|
987
|
-
get_permission_settings_path(project_root), quiet=True
|
|
988
|
-
)
|
|
1562
|
+
install_user_prompt_submit_hook(get_submit_settings_path(project_root), quiet=True)
|
|
1563
|
+
install_permission_request_hook(get_permission_settings_path(project_root), quiet=True)
|
|
989
1564
|
except Exception:
|
|
990
1565
|
pass
|
|
991
1566
|
|
|
@@ -1010,20 +1585,87 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
1010
1585
|
except Exception as e:
|
|
1011
1586
|
self.app.notify(f"Failed: {e}", title="Agent", severity="error")
|
|
1012
1587
|
|
|
1013
|
-
|
|
1588
|
+
def _request_switch_to_terminal(self, terminal_id: str) -> None:
|
|
1589
|
+
"""Switch terminals without blocking the UI thread."""
|
|
1590
|
+
terminal_id = (terminal_id or "").strip()
|
|
1014
1591
|
if not terminal_id:
|
|
1015
1592
|
return
|
|
1016
1593
|
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1594
|
+
self._active_terminal_id = terminal_id
|
|
1595
|
+
self._update_active_terminal_ui(terminal_id)
|
|
1596
|
+
self._rendered_fingerprint = (
|
|
1597
|
+
self._fingerprint(self._agents) + f"|active:{self._active_terminal_id}"
|
|
1598
|
+
)
|
|
1599
|
+
|
|
1600
|
+
self._switch_seq += 1
|
|
1601
|
+
seq = self._switch_seq
|
|
1602
|
+
|
|
1603
|
+
if self._switch_worker is not None and self._switch_worker.state in (
|
|
1604
|
+
WorkerState.PENDING,
|
|
1605
|
+
WorkerState.RUNNING,
|
|
1606
|
+
):
|
|
1607
|
+
try:
|
|
1608
|
+
self._switch_worker.cancel()
|
|
1609
|
+
except Exception:
|
|
1610
|
+
pass
|
|
1611
|
+
|
|
1612
|
+
agents_snapshot = self._agents
|
|
1613
|
+
|
|
1614
|
+
def work() -> dict:
|
|
1615
|
+
# Prefer cached window_id to avoid a blocking tmux scan on every click.
|
|
1616
|
+
window_id = None
|
|
1617
|
+
try:
|
|
1618
|
+
for agent in agents_snapshot:
|
|
1619
|
+
for term in agent.get("terminals") or []:
|
|
1620
|
+
if term.get("terminal_id") == terminal_id:
|
|
1621
|
+
window_id = (term.get("window_id") or "").strip() or None
|
|
1622
|
+
break
|
|
1623
|
+
if window_id:
|
|
1624
|
+
break
|
|
1625
|
+
except Exception:
|
|
1626
|
+
window_id = None
|
|
1021
1627
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1628
|
+
if not window_id:
|
|
1629
|
+
# Fall back to querying tmux (best-effort).
|
|
1630
|
+
window_id = self._find_window(terminal_id)
|
|
1631
|
+
|
|
1632
|
+
if not window_id:
|
|
1633
|
+
# If the terminal was closed from within the shell (e.g. `exit`), the DB record can
|
|
1634
|
+
# remain "active" briefly. Trigger a refresh so the stale button disappears.
|
|
1635
|
+
try:
|
|
1636
|
+
from ...db import get_database
|
|
1637
|
+
|
|
1638
|
+
db = get_database(read_only=False)
|
|
1639
|
+
db.update_agent(terminal_id, status="inactive")
|
|
1640
|
+
except Exception:
|
|
1641
|
+
pass
|
|
1642
|
+
return {
|
|
1643
|
+
"seq": seq,
|
|
1644
|
+
"terminal_id": terminal_id,
|
|
1645
|
+
"ok": False,
|
|
1646
|
+
"error": "Window not found",
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
if not tmux_manager.select_inner_window(window_id):
|
|
1650
|
+
return {
|
|
1651
|
+
"seq": seq,
|
|
1652
|
+
"terminal_id": terminal_id,
|
|
1653
|
+
"window_id": window_id,
|
|
1654
|
+
"ok": False,
|
|
1655
|
+
"error": "Failed to switch",
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
# Prefer showing the terminal after switching (best-effort).
|
|
1659
|
+
if not tmux_manager.focus_right_pane():
|
|
1660
|
+
logger.warning(
|
|
1661
|
+
"Switch terminal: focus_right_pane failed (terminal_id=%s window_id=%s)",
|
|
1662
|
+
terminal_id,
|
|
1663
|
+
window_id,
|
|
1664
|
+
)
|
|
1024
1665
|
tmux_manager.clear_attention(window_id)
|
|
1025
|
-
|
|
1026
|
-
|
|
1666
|
+
return {"seq": seq, "terminal_id": terminal_id, "window_id": window_id, "ok": True}
|
|
1667
|
+
|
|
1668
|
+
self._switch_worker = self.run_worker(work, thread=True, exit_on_error=False)
|
|
1027
1669
|
|
|
1028
1670
|
async def _close_terminal(self, terminal_id: str) -> None:
|
|
1029
1671
|
if not terminal_id:
|
|
@@ -1064,14 +1706,10 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
1064
1706
|
db = get_database(read_only=True)
|
|
1065
1707
|
sessions = db.get_sessions_by_agent_id(agent_id)
|
|
1066
1708
|
if not sessions:
|
|
1067
|
-
self.app.notify(
|
|
1068
|
-
"Agent has no sessions to share", title="Share", severity="warning"
|
|
1069
|
-
)
|
|
1709
|
+
self.app.notify("Agent has no sessions to share", title="Share", severity="warning")
|
|
1070
1710
|
return
|
|
1071
1711
|
except Exception as e:
|
|
1072
|
-
self.app.notify(
|
|
1073
|
-
f"Failed to check sessions: {e}", title="Share", severity="error"
|
|
1074
|
-
)
|
|
1712
|
+
self.app.notify(f"Failed to check sessions: {e}", title="Share", severity="error")
|
|
1075
1713
|
return
|
|
1076
1714
|
|
|
1077
1715
|
# Store agent_id for the worker callback
|
|
@@ -1128,9 +1766,7 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
1128
1766
|
try:
|
|
1129
1767
|
match = re.search(r"\{.*\}", output, re.DOTALL)
|
|
1130
1768
|
if match:
|
|
1131
|
-
result["json"] = json_module.loads(
|
|
1132
|
-
match.group(0), strict=False
|
|
1133
|
-
)
|
|
1769
|
+
result["json"] = json_module.loads(match.group(0), strict=False)
|
|
1134
1770
|
except Exception:
|
|
1135
1771
|
result["json"] = None
|
|
1136
1772
|
|
|
@@ -1165,9 +1801,7 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
1165
1801
|
share_link = payload.get("share_link") or payload.get("share_url")
|
|
1166
1802
|
if not share_link:
|
|
1167
1803
|
share_link = result.get("share_link_guess")
|
|
1168
|
-
slack_message = (
|
|
1169
|
-
payload.get("slack_message") if isinstance(payload, dict) else None
|
|
1170
|
-
)
|
|
1804
|
+
slack_message = payload.get("slack_message") if isinstance(payload, dict) else None
|
|
1171
1805
|
if not slack_message:
|
|
1172
1806
|
try:
|
|
1173
1807
|
from ...db import get_database
|
|
@@ -1190,17 +1824,13 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
1190
1824
|
copied = copy_text(self.app, text_to_copy)
|
|
1191
1825
|
|
|
1192
1826
|
suffix = " (copied)" if copied else ""
|
|
1193
|
-
self.app.notify(
|
|
1194
|
-
f"Share link: {share_link}{suffix}", title="Share", timeout=6
|
|
1195
|
-
)
|
|
1827
|
+
self.app.notify(f"Share link: {share_link}{suffix}", title="Share", timeout=6)
|
|
1196
1828
|
elif exit_code == 0:
|
|
1197
1829
|
self.app.notify("Share completed", title="Share", timeout=3)
|
|
1198
1830
|
else:
|
|
1199
1831
|
extra = result.get("stderr") or ""
|
|
1200
1832
|
suffix = f": {extra}" if extra else ""
|
|
1201
|
-
self.app.notify(
|
|
1202
|
-
f"Share failed (exit {exit_code}){suffix}", title="Share", timeout=6
|
|
1203
|
-
)
|
|
1833
|
+
self.app.notify(f"Share failed (exit {exit_code}){suffix}", title="Share", timeout=6)
|
|
1204
1834
|
|
|
1205
1835
|
async def _sync_agent(self, agent_id: str) -> None:
|
|
1206
1836
|
"""Sync all sessions for an agent with remote share."""
|
|
@@ -1294,6 +1924,4 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
1294
1924
|
if copied:
|
|
1295
1925
|
self.app.notify("Share link copied to clipboard", title="Link", timeout=3)
|
|
1296
1926
|
else:
|
|
1297
|
-
self.app.notify(
|
|
1298
|
-
f"Failed to copy. Link: {share_url}", title="Link", severity="warning"
|
|
1299
|
-
)
|
|
1927
|
+
self.app.notify(f"Failed to copy. Link: {share_url}", title="Link", severity="warning")
|