aline-ai 0.6.6__py3-none-any.whl → 0.6.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aline_ai-0.6.6.dist-info → aline_ai-0.6.7.dist-info}/METADATA +1 -1
- {aline_ai-0.6.6.dist-info → aline_ai-0.6.7.dist-info}/RECORD +25 -25
- realign/__init__.py +1 -1
- realign/agent_names.py +2 -2
- realign/claude_hooks/terminal_state.py +32 -1
- realign/codex_detector.py +17 -2
- realign/codex_home.py +24 -6
- realign/commands/doctor.py +74 -1
- realign/commands/export_shares.py +151 -0
- realign/commands/import_shares.py +203 -1
- realign/commands/sync_agent.py +347 -0
- realign/dashboard/screens/create_agent_info.py +131 -20
- realign/dashboard/styles/dashboard.tcss +0 -73
- realign/dashboard/tmux_manager.py +36 -10
- realign/dashboard/widgets/__init__.py +0 -2
- realign/dashboard/widgets/agents_panel.py +142 -23
- realign/db/base.py +43 -1
- realign/db/schema.py +60 -2
- realign/db/sqlite_db.py +176 -1
- realign/watcher_core.py +133 -2
- realign/worker_core.py +37 -2
- realign/dashboard/widgets/terminal_panel.py +0 -1688
- {aline_ai-0.6.6.dist-info → aline_ai-0.6.7.dist-info}/WHEEL +0 -0
- {aline_ai-0.6.6.dist-info → aline_ai-0.6.7.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.6.6.dist-info → aline_ai-0.6.7.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.6.6.dist-info → aline_ai-0.6.7.dist-info}/top_level.txt +0 -0
|
@@ -7,9 +7,10 @@ from typing import Optional
|
|
|
7
7
|
|
|
8
8
|
from textual.app import ComposeResult
|
|
9
9
|
from textual.binding import Binding
|
|
10
|
-
from textual.containers import Container, Horizontal
|
|
10
|
+
from textual.containers import Container, Horizontal, Vertical
|
|
11
11
|
from textual.screen import ModalScreen
|
|
12
12
|
from textual.widgets import Button, Input, Label, Static
|
|
13
|
+
from textual.worker import Worker, WorkerState
|
|
13
14
|
|
|
14
15
|
from ...logging_config import setup_logger
|
|
15
16
|
|
|
@@ -17,8 +18,9 @@ logger = setup_logger("realign.dashboard.screens.create_agent_info", "dashboard.
|
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
|
|
20
|
-
"""Modal to create a new agent profile.
|
|
21
|
+
"""Modal to create a new agent profile or import from a share link.
|
|
21
22
|
|
|
23
|
+
Both options are shown together; the user picks one.
|
|
22
24
|
Returns a dict with agent info on success, None on cancel.
|
|
23
25
|
"""
|
|
24
26
|
|
|
@@ -32,7 +34,7 @@ class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
|
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
CreateAgentInfoScreen #create-agent-info-root {
|
|
35
|
-
width:
|
|
37
|
+
width: 65;
|
|
36
38
|
height: auto;
|
|
37
39
|
max-height: 80%;
|
|
38
40
|
padding: 1 2;
|
|
@@ -54,41 +56,92 @@ class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
CreateAgentInfoScreen Input {
|
|
57
|
-
height: auto;
|
|
58
59
|
margin-top: 0;
|
|
60
|
+
border: none;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
CreateAgentInfoScreen #or-separator {
|
|
64
|
+
height: auto;
|
|
65
|
+
margin-top: 1;
|
|
66
|
+
margin-bottom: 0;
|
|
67
|
+
text-align: center;
|
|
68
|
+
color: $text-muted;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
CreateAgentInfoScreen #import-status {
|
|
72
|
+
height: auto;
|
|
73
|
+
margin-top: 1;
|
|
74
|
+
color: $text-muted;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
CreateAgentInfoScreen #create-buttons {
|
|
78
|
+
height: auto;
|
|
79
|
+
margin-top: 1;
|
|
80
|
+
align: right middle;
|
|
59
81
|
}
|
|
60
82
|
|
|
61
|
-
CreateAgentInfoScreen #buttons {
|
|
83
|
+
CreateAgentInfoScreen #import-buttons {
|
|
62
84
|
height: auto;
|
|
63
|
-
margin-top:
|
|
85
|
+
margin-top: 1;
|
|
64
86
|
align: right middle;
|
|
65
87
|
}
|
|
66
88
|
|
|
67
|
-
CreateAgentInfoScreen #buttons Button {
|
|
89
|
+
CreateAgentInfoScreen #create-buttons Button {
|
|
90
|
+
margin-left: 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
CreateAgentInfoScreen #import-buttons Button {
|
|
68
94
|
margin-left: 1;
|
|
69
95
|
}
|
|
70
96
|
"""
|
|
71
97
|
|
|
98
|
+
def __init__(self) -> None:
|
|
99
|
+
super().__init__()
|
|
100
|
+
self._import_worker: Optional[Worker] = None
|
|
101
|
+
from ...agent_names import generate_agent_name
|
|
102
|
+
self._default_name: str = generate_agent_name()
|
|
103
|
+
|
|
72
104
|
def compose(self) -> ComposeResult:
|
|
73
105
|
with Container(id="create-agent-info-root"):
|
|
74
106
|
yield Static("Create Agent Profile", id="create-agent-info-title")
|
|
75
107
|
|
|
108
|
+
# --- Create New section ---
|
|
76
109
|
yield Label("Name", classes="section-label")
|
|
77
|
-
yield Input(placeholder=
|
|
78
|
-
|
|
79
|
-
yield Label("Description", classes="section-label")
|
|
80
|
-
yield Input(placeholder="Optional description", id="agent-description")
|
|
110
|
+
yield Input(placeholder=self._default_name, id="agent-name")
|
|
81
111
|
|
|
82
|
-
with Horizontal(id="buttons"):
|
|
112
|
+
with Horizontal(id="create-buttons"):
|
|
83
113
|
yield Button("Cancel", id="cancel")
|
|
84
114
|
yield Button("Create", id="create", variant="primary")
|
|
85
115
|
|
|
116
|
+
# --- Separator ---
|
|
117
|
+
yield Static("-- Or --", id="or-separator")
|
|
118
|
+
|
|
119
|
+
# --- Import from Link section ---
|
|
120
|
+
yield Label("Import from Link", classes="section-label")
|
|
121
|
+
yield Input(placeholder="https://realign-server.vercel.app/share/...", id="share-url")
|
|
122
|
+
|
|
123
|
+
yield Label("Password (optional)", classes="section-label")
|
|
124
|
+
yield Input(placeholder="Leave blank if not password-protected", id="share-password", password=True)
|
|
125
|
+
|
|
126
|
+
yield Static("", id="import-status")
|
|
127
|
+
|
|
128
|
+
with Horizontal(id="import-buttons"):
|
|
129
|
+
yield Button("Import", id="import", variant="primary")
|
|
130
|
+
|
|
86
131
|
def on_mount(self) -> None:
|
|
87
132
|
self.query_one("#agent-name", Input).focus()
|
|
88
133
|
|
|
89
134
|
def action_close(self) -> None:
|
|
90
135
|
self.dismiss(None)
|
|
91
136
|
|
|
137
|
+
def _set_busy(self, busy: bool) -> None:
|
|
138
|
+
self.query_one("#agent-name", Input).disabled = busy
|
|
139
|
+
self.query_one("#share-url", Input).disabled = busy
|
|
140
|
+
self.query_one("#share-password", Input).disabled = busy
|
|
141
|
+
self.query_one("#create", Button).disabled = busy
|
|
142
|
+
self.query_one("#cancel", Button).disabled = busy
|
|
143
|
+
self.query_one("#import", Button).disabled = busy
|
|
144
|
+
|
|
92
145
|
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
93
146
|
button_id = event.button.id or ""
|
|
94
147
|
|
|
@@ -100,28 +153,30 @@ class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
|
|
|
100
153
|
await self._create_agent()
|
|
101
154
|
return
|
|
102
155
|
|
|
156
|
+
if button_id == "import":
|
|
157
|
+
await self._import_agent()
|
|
158
|
+
return
|
|
159
|
+
|
|
103
160
|
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
104
161
|
"""Handle enter key in input fields."""
|
|
105
|
-
|
|
162
|
+
input_id = event.input.id or ""
|
|
163
|
+
if input_id == "agent-name":
|
|
164
|
+
await self._create_agent()
|
|
165
|
+
elif input_id in ("share-url", "share-password"):
|
|
166
|
+
await self._import_agent()
|
|
106
167
|
|
|
107
168
|
async def _create_agent(self) -> None:
|
|
108
169
|
"""Create the agent profile."""
|
|
109
170
|
try:
|
|
110
|
-
from ...agent_names import generate_agent_name
|
|
111
171
|
from ...db import get_database
|
|
112
172
|
|
|
113
173
|
name_input = self.query_one("#agent-name", Input).value.strip()
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
# Generate random name if not provided
|
|
117
|
-
name = name_input or generate_agent_name()
|
|
174
|
+
name = name_input or self._default_name
|
|
118
175
|
|
|
119
176
|
agent_id = str(uuid.uuid4())
|
|
120
177
|
|
|
121
178
|
db = get_database(read_only=False)
|
|
122
179
|
record = db.get_or_create_agent_info(agent_id, name=name)
|
|
123
|
-
if description:
|
|
124
|
-
record = db.update_agent_info(agent_id, description=description)
|
|
125
180
|
|
|
126
181
|
self.dismiss({
|
|
127
182
|
"id": record.id,
|
|
@@ -131,3 +186,59 @@ class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
|
|
|
131
186
|
except Exception as e:
|
|
132
187
|
logger.error(f"Failed to create agent: {e}")
|
|
133
188
|
self.app.notify(f"Failed to create agent: {e}", severity="error")
|
|
189
|
+
|
|
190
|
+
async def _import_agent(self) -> None:
|
|
191
|
+
"""Import an agent from a share link."""
|
|
192
|
+
share_url = self.query_one("#share-url", Input).value.strip()
|
|
193
|
+
password = self.query_one("#share-password", Input).value.strip() or None
|
|
194
|
+
|
|
195
|
+
if not share_url:
|
|
196
|
+
self.app.notify("Please enter a share URL", severity="warning")
|
|
197
|
+
self.query_one("#share-url", Input).focus()
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
if "/share/" not in share_url:
|
|
201
|
+
self.app.notify("Invalid share URL format", severity="warning")
|
|
202
|
+
self.query_one("#share-url", Input).focus()
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
status = self.query_one("#import-status", Static)
|
|
206
|
+
status.update("Importing...")
|
|
207
|
+
self._set_busy(True)
|
|
208
|
+
|
|
209
|
+
def do_import() -> dict:
|
|
210
|
+
from ...commands.import_shares import import_agent_from_share
|
|
211
|
+
|
|
212
|
+
return import_agent_from_share(share_url, password=password)
|
|
213
|
+
|
|
214
|
+
self._import_worker = self.run_worker(do_import, thread=True, exit_on_error=False)
|
|
215
|
+
|
|
216
|
+
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
217
|
+
if self._import_worker is None or event.worker is not self._import_worker:
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
status = self.query_one("#import-status", Static)
|
|
221
|
+
|
|
222
|
+
if event.state == WorkerState.ERROR:
|
|
223
|
+
err = self._import_worker.error if self._import_worker else "Unknown error"
|
|
224
|
+
status.update(f"Error: {err}")
|
|
225
|
+
self._set_busy(False)
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
if event.state != WorkerState.SUCCESS:
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
result = self._import_worker.result if self._import_worker else {}
|
|
232
|
+
if not result or not result.get("success"):
|
|
233
|
+
error_msg = (result or {}).get("error", "Import failed")
|
|
234
|
+
status.update(f"Error: {error_msg}")
|
|
235
|
+
self._set_busy(False)
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
self.dismiss({
|
|
239
|
+
"id": result["agent_id"],
|
|
240
|
+
"name": result["agent_name"],
|
|
241
|
+
"description": result.get("agent_description", ""),
|
|
242
|
+
"imported": True,
|
|
243
|
+
"sessions_imported": result.get("sessions_imported", 0),
|
|
244
|
+
})
|
|
@@ -156,79 +156,6 @@ Tab:hover {
|
|
|
156
156
|
background: $surface-lighten-1;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
/* Terminal tab: compact layout without "boxed" buttons/panels. */
|
|
160
|
-
TerminalPanel {
|
|
161
|
-
padding: 0 1;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
TerminalPanel:focus {
|
|
165
|
-
border: none;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
TerminalPanel .summary {
|
|
169
|
-
background: transparent;
|
|
170
|
-
border: none;
|
|
171
|
-
padding: 0;
|
|
172
|
-
margin: 0 0 1 0;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
TerminalPanel .list {
|
|
176
|
-
background: transparent;
|
|
177
|
-
border: none;
|
|
178
|
-
padding: 0;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
TerminalPanel Button {
|
|
182
|
-
min-width: 0;
|
|
183
|
-
background: transparent;
|
|
184
|
-
border: none;
|
|
185
|
-
padding: 0 1;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
TerminalPanel Button:hover {
|
|
189
|
-
background: $surface-lighten-1;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
TerminalPanel .terminal-row Button.terminal-switch.active {
|
|
193
|
-
background: $primary;
|
|
194
|
-
color: $text;
|
|
195
|
-
text-style: bold;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
TerminalPanel .terminal-row Button.terminal-switch {
|
|
199
|
-
text-align: left;
|
|
200
|
-
content-align: left top;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
TerminalPanel .terminal-row Button.terminal-close {
|
|
204
|
-
padding: 0;
|
|
205
|
-
width: 3;
|
|
206
|
-
min-width: 3;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
TerminalPanel .terminal-row Button.terminal-toggle {
|
|
210
|
-
padding: 0;
|
|
211
|
-
width: 3;
|
|
212
|
-
min-width: 3;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
TerminalPanel .context-sessions {
|
|
216
|
-
border: none;
|
|
217
|
-
padding: 0;
|
|
218
|
-
height: 8;
|
|
219
|
-
overflow-y: auto;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
TerminalPanel Button.context-session {
|
|
223
|
-
text-align: left;
|
|
224
|
-
content-align: left middle;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
TerminalPanel .context-sessions Static {
|
|
228
|
-
text-align: left;
|
|
229
|
-
content-align: left middle;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
159
|
/* Agents tab: compact layout matching terminal panel */
|
|
233
160
|
AgentsPanel {
|
|
234
161
|
padding: 0 1;
|
|
@@ -776,16 +776,42 @@ def list_inner_windows() -> list[InnerWindow]:
|
|
|
776
776
|
|
|
777
777
|
if terminal_id:
|
|
778
778
|
persisted = state.get(terminal_id) or {}
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
if
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
if
|
|
788
|
-
|
|
779
|
+
updates: dict[str, str] = {}
|
|
780
|
+
persisted_provider = (persisted.get("provider") or "").strip()
|
|
781
|
+
if persisted_provider and persisted_provider != (provider or "").strip():
|
|
782
|
+
provider = persisted_provider
|
|
783
|
+
updates[OPT_PROVIDER] = persisted_provider
|
|
784
|
+
if not provider and persisted_provider:
|
|
785
|
+
provider = persisted_provider
|
|
786
|
+
persisted_session_type = (persisted.get("session_type") or "").strip()
|
|
787
|
+
if persisted_session_type and persisted_session_type != (session_type or "").strip():
|
|
788
|
+
session_type = persisted_session_type
|
|
789
|
+
updates[OPT_SESSION_TYPE] = persisted_session_type
|
|
790
|
+
if not session_type and persisted_session_type:
|
|
791
|
+
session_type = persisted_session_type
|
|
792
|
+
persisted_session_id = (persisted.get("session_id") or "").strip()
|
|
793
|
+
if persisted_session_id and persisted_session_id != (session_id or "").strip():
|
|
794
|
+
session_id = persisted_session_id
|
|
795
|
+
updates[OPT_SESSION_ID] = persisted_session_id
|
|
796
|
+
if not session_id and persisted_session_id:
|
|
797
|
+
session_id = persisted_session_id
|
|
798
|
+
persisted_transcript = (persisted.get("transcript_path") or "").strip()
|
|
799
|
+
if persisted_transcript and persisted_transcript != (transcript_path or "").strip():
|
|
800
|
+
transcript_path = persisted_transcript
|
|
801
|
+
updates[OPT_TRANSCRIPT_PATH] = persisted_transcript
|
|
802
|
+
if not transcript_path and persisted_transcript:
|
|
803
|
+
transcript_path = persisted_transcript
|
|
804
|
+
persisted_context = (persisted.get("context_id") or "").strip()
|
|
805
|
+
if persisted_context and persisted_context != (context_id or "").strip():
|
|
806
|
+
context_id = persisted_context
|
|
807
|
+
updates[OPT_CONTEXT_ID] = persisted_context
|
|
808
|
+
if not context_id and persisted_context:
|
|
809
|
+
context_id = persisted_context
|
|
810
|
+
if updates:
|
|
811
|
+
try:
|
|
812
|
+
set_inner_window_options(window_id, updates)
|
|
813
|
+
except Exception:
|
|
814
|
+
pass
|
|
789
815
|
|
|
790
816
|
transcript_session_id = _session_id_from_transcript_path(transcript_path)
|
|
791
817
|
if transcript_session_id:
|
|
@@ -8,7 +8,6 @@ from .events_table import EventsTable
|
|
|
8
8
|
from .config_panel import ConfigPanel
|
|
9
9
|
from .search_panel import SearchPanel
|
|
10
10
|
from .openable_table import OpenableDataTable
|
|
11
|
-
from .terminal_panel import TerminalPanel
|
|
12
11
|
from .agents_panel import AgentsPanel
|
|
13
12
|
|
|
14
13
|
__all__ = [
|
|
@@ -20,6 +19,5 @@ __all__ = [
|
|
|
20
19
|
"ConfigPanel",
|
|
21
20
|
"SearchPanel",
|
|
22
21
|
"OpenableDataTable",
|
|
23
|
-
"TerminalPanel",
|
|
24
22
|
"AgentsPanel",
|
|
25
23
|
]
|
|
@@ -165,7 +165,9 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
165
165
|
self._agents: list[dict] = []
|
|
166
166
|
self._refresh_worker: Optional[Worker] = None
|
|
167
167
|
self._share_worker: Optional[Worker] = None
|
|
168
|
+
self._sync_worker: Optional[Worker] = None
|
|
168
169
|
self._share_agent_id: Optional[str] = None
|
|
170
|
+
self._sync_agent_id: Optional[str] = None
|
|
169
171
|
self._refresh_timer = None
|
|
170
172
|
|
|
171
173
|
def compose(self) -> ComposeResult:
|
|
@@ -216,18 +218,27 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
216
218
|
agent_infos = db.list_agent_info()
|
|
217
219
|
active_terminals = db.list_agents(status="active", limit=1000)
|
|
218
220
|
|
|
219
|
-
#
|
|
221
|
+
# Latest window links per terminal (V23)
|
|
222
|
+
latest_links = db.list_latest_window_links(limit=2000)
|
|
223
|
+
link_by_terminal = {l.terminal_id: l for l in latest_links if l.terminal_id}
|
|
224
|
+
|
|
225
|
+
# Get tmux windows to retrieve window id and fallback session_id
|
|
220
226
|
tmux_windows = tmux_manager.list_inner_windows()
|
|
221
|
-
# Map terminal_id -> tmux window
|
|
222
227
|
terminal_to_window = {
|
|
223
228
|
w.terminal_id: w for w in tmux_windows if w.terminal_id
|
|
224
229
|
}
|
|
225
230
|
|
|
226
|
-
# Collect all session_ids
|
|
227
|
-
session_ids = [
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
+
# Collect all session_ids for title lookup
|
|
232
|
+
session_ids: list[str] = []
|
|
233
|
+
for t in active_terminals:
|
|
234
|
+
link = link_by_terminal.get(t.id)
|
|
235
|
+
if link and link.session_id:
|
|
236
|
+
session_ids.append(link.session_id)
|
|
237
|
+
continue
|
|
238
|
+
window = terminal_to_window.get(t.id)
|
|
239
|
+
if window and window.session_id:
|
|
240
|
+
session_ids.append(window.session_id)
|
|
241
|
+
|
|
231
242
|
titles = self._fetch_session_titles(session_ids)
|
|
232
243
|
|
|
233
244
|
# Map agent_info.id -> list of terminals
|
|
@@ -236,16 +247,21 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
236
247
|
# Find which agent_info this terminal belongs to
|
|
237
248
|
agent_info_id = None
|
|
238
249
|
|
|
250
|
+
link = link_by_terminal.get(t.id)
|
|
251
|
+
|
|
239
252
|
# Method 1: Check source field for "agent:{agent_info_id}" format
|
|
240
253
|
source = t.source or ""
|
|
241
254
|
if source.startswith("agent:"):
|
|
242
|
-
agent_info_id = source[6:]
|
|
255
|
+
agent_info_id = source[6:]
|
|
256
|
+
|
|
257
|
+
# Method 2: WindowLink agent_id
|
|
258
|
+
if not agent_info_id and link and link.agent_id:
|
|
259
|
+
agent_info_id = link.agent_id
|
|
243
260
|
|
|
244
|
-
# Method
|
|
261
|
+
# Method 3: Fallback - check tmux window's session.agent_id
|
|
245
262
|
if not agent_info_id:
|
|
246
263
|
window = terminal_to_window.get(t.id)
|
|
247
264
|
if window and window.session_id:
|
|
248
|
-
# Look up session to get agent_id
|
|
249
265
|
session = db.get_session_by_id(window.session_id)
|
|
250
266
|
if session:
|
|
251
267
|
agent_info_id = session.agent_id
|
|
@@ -254,16 +270,18 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
254
270
|
if agent_info_id not in agent_to_terminals:
|
|
255
271
|
agent_to_terminals[agent_info_id] = []
|
|
256
272
|
|
|
257
|
-
# Get session_id
|
|
273
|
+
# Get session_id from windowlink (preferred) or tmux window
|
|
258
274
|
window = terminal_to_window.get(t.id)
|
|
259
|
-
session_id =
|
|
275
|
+
session_id = (
|
|
276
|
+
link.session_id if link and link.session_id else (window.session_id if window else None)
|
|
277
|
+
)
|
|
260
278
|
title = titles.get(session_id, "") if session_id else ""
|
|
261
279
|
|
|
262
280
|
agent_to_terminals[agent_info_id].append(
|
|
263
281
|
{
|
|
264
282
|
"terminal_id": t.id,
|
|
265
283
|
"session_id": session_id,
|
|
266
|
-
"provider": t.provider or "",
|
|
284
|
+
"provider": link.provider if link and link.provider else (t.provider or ""),
|
|
267
285
|
"session_type": t.session_type or "",
|
|
268
286
|
"title": title,
|
|
269
287
|
"cwd": t.cwd or "",
|
|
@@ -278,12 +296,15 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
278
296
|
"name": info.name,
|
|
279
297
|
"description": info.description or "",
|
|
280
298
|
"terminals": terminals,
|
|
299
|
+
"share_url": getattr(info, "share_url", None),
|
|
300
|
+
"last_synced_at": getattr(info, "last_synced_at", None),
|
|
281
301
|
}
|
|
282
302
|
)
|
|
283
303
|
except Exception as e:
|
|
284
304
|
logger.debug(f"Failed to collect agents: {e}")
|
|
285
305
|
return agents
|
|
286
306
|
|
|
307
|
+
|
|
287
308
|
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
288
309
|
# Handle refresh worker
|
|
289
310
|
if self._refresh_worker is not None and event.worker is self._refresh_worker:
|
|
@@ -302,6 +323,10 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
302
323
|
if self._share_worker is not None and event.worker is self._share_worker:
|
|
303
324
|
self._handle_share_worker_state_changed(event)
|
|
304
325
|
|
|
326
|
+
# Handle sync worker
|
|
327
|
+
if self._sync_worker is not None and event.worker is self._sync_worker:
|
|
328
|
+
self._handle_sync_worker_state_changed(event)
|
|
329
|
+
|
|
305
330
|
async def _render_agents(self) -> None:
|
|
306
331
|
async with self._refresh_lock:
|
|
307
332
|
try:
|
|
@@ -339,15 +364,25 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
339
364
|
)
|
|
340
365
|
)
|
|
341
366
|
|
|
342
|
-
# Share button
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
367
|
+
# Share or Sync button (Sync if agent already has a share_url)
|
|
368
|
+
if agent.get("share_url"):
|
|
369
|
+
await row.mount(
|
|
370
|
+
Button(
|
|
371
|
+
"Sync",
|
|
372
|
+
id=f"sync-{safe_id}",
|
|
373
|
+
name=agent["id"],
|
|
374
|
+
classes="agent-share",
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
else:
|
|
378
|
+
await row.mount(
|
|
379
|
+
Button(
|
|
380
|
+
"Share",
|
|
381
|
+
id=f"share-{safe_id}",
|
|
382
|
+
name=agent["id"],
|
|
383
|
+
classes="agent-share",
|
|
384
|
+
)
|
|
349
385
|
)
|
|
350
|
-
)
|
|
351
386
|
|
|
352
387
|
# Create terminal button
|
|
353
388
|
await row.mount(
|
|
@@ -500,6 +535,11 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
500
535
|
await self._share_agent(agent_id)
|
|
501
536
|
return
|
|
502
537
|
|
|
538
|
+
if btn_id.startswith("sync-"):
|
|
539
|
+
agent_id = event.button.name or ""
|
|
540
|
+
await self._sync_agent(agent_id)
|
|
541
|
+
return
|
|
542
|
+
|
|
503
543
|
if btn_id.startswith("switch-"):
|
|
504
544
|
terminal_id = event.button.name or ""
|
|
505
545
|
await self._switch_to_terminal(terminal_id)
|
|
@@ -543,7 +583,13 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
543
583
|
|
|
544
584
|
def _on_create_result(self, result: dict | None) -> None:
|
|
545
585
|
if result:
|
|
546
|
-
|
|
586
|
+
if result.get("imported"):
|
|
587
|
+
n = result.get("sessions_imported", 0)
|
|
588
|
+
self.app.notify(
|
|
589
|
+
f"Imported: {result.get('name')} ({n} sessions)", title="Agent"
|
|
590
|
+
)
|
|
591
|
+
else:
|
|
592
|
+
self.app.notify(f"Created: {result.get('name')}", title="Agent")
|
|
547
593
|
self.refresh_data()
|
|
548
594
|
|
|
549
595
|
async def _create_terminal_for_agent(self, agent_id: str) -> None:
|
|
@@ -619,7 +665,7 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
619
665
|
try:
|
|
620
666
|
from ...codex_home import prepare_codex_home
|
|
621
667
|
|
|
622
|
-
codex_home = prepare_codex_home(terminal_id
|
|
668
|
+
codex_home = prepare_codex_home(terminal_id)
|
|
623
669
|
except Exception:
|
|
624
670
|
codex_home = None
|
|
625
671
|
|
|
@@ -1127,3 +1173,76 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
1127
1173
|
self.app.notify(
|
|
1128
1174
|
f"Share failed (exit {exit_code}){suffix}", title="Share", timeout=6
|
|
1129
1175
|
)
|
|
1176
|
+
|
|
1177
|
+
async def _sync_agent(self, agent_id: str) -> None:
|
|
1178
|
+
"""Sync all sessions for an agent with remote share."""
|
|
1179
|
+
if not agent_id:
|
|
1180
|
+
return
|
|
1181
|
+
|
|
1182
|
+
# Check if sync is already in progress
|
|
1183
|
+
if self._sync_worker is not None and self._sync_worker.state in (
|
|
1184
|
+
WorkerState.PENDING,
|
|
1185
|
+
WorkerState.RUNNING,
|
|
1186
|
+
):
|
|
1187
|
+
return
|
|
1188
|
+
|
|
1189
|
+
self._sync_agent_id = agent_id
|
|
1190
|
+
|
|
1191
|
+
app = self.app
|
|
1192
|
+
|
|
1193
|
+
def progress_callback(message: str) -> None:
|
|
1194
|
+
try:
|
|
1195
|
+
app.call_from_thread(app.notify, message, title="Sync", timeout=3)
|
|
1196
|
+
except Exception:
|
|
1197
|
+
pass
|
|
1198
|
+
|
|
1199
|
+
def work() -> dict:
|
|
1200
|
+
from ...commands.sync_agent import sync_agent_command
|
|
1201
|
+
|
|
1202
|
+
return sync_agent_command(
|
|
1203
|
+
agent_id=agent_id,
|
|
1204
|
+
progress_callback=progress_callback,
|
|
1205
|
+
)
|
|
1206
|
+
|
|
1207
|
+
self.app.notify("Starting sync...", title="Sync", timeout=2)
|
|
1208
|
+
self._sync_worker = self.run_worker(work, thread=True, exit_on_error=False)
|
|
1209
|
+
|
|
1210
|
+
def _handle_sync_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
1211
|
+
"""Handle sync worker state changes."""
|
|
1212
|
+
if event.state == WorkerState.ERROR:
|
|
1213
|
+
err = self._sync_worker.error if self._sync_worker else "Unknown error"
|
|
1214
|
+
self.app.notify(f"Sync failed: {err}", title="Sync", severity="error")
|
|
1215
|
+
return
|
|
1216
|
+
|
|
1217
|
+
if event.state != WorkerState.SUCCESS:
|
|
1218
|
+
return
|
|
1219
|
+
|
|
1220
|
+
result = self._sync_worker.result if self._sync_worker else {}
|
|
1221
|
+
|
|
1222
|
+
if result.get("success"):
|
|
1223
|
+
pulled = result.get("sessions_pulled", 0)
|
|
1224
|
+
pushed = result.get("sessions_pushed", 0)
|
|
1225
|
+
|
|
1226
|
+
# Copy share URL to clipboard
|
|
1227
|
+
agent_id = self._sync_agent_id
|
|
1228
|
+
share_url = None
|
|
1229
|
+
if agent_id:
|
|
1230
|
+
agent = next((a for a in self._agents if a["id"] == agent_id), None)
|
|
1231
|
+
if agent:
|
|
1232
|
+
share_url = agent.get("share_url")
|
|
1233
|
+
|
|
1234
|
+
if share_url:
|
|
1235
|
+
copied = copy_text(self.app, share_url)
|
|
1236
|
+
suffix = " (link copied)" if copied else ""
|
|
1237
|
+
else:
|
|
1238
|
+
suffix = ""
|
|
1239
|
+
|
|
1240
|
+
self.app.notify(
|
|
1241
|
+
f"Synced: pulled {pulled}, pushed {pushed} session(s){suffix}",
|
|
1242
|
+
title="Sync",
|
|
1243
|
+
timeout=6,
|
|
1244
|
+
)
|
|
1245
|
+
self.refresh_data()
|
|
1246
|
+
else:
|
|
1247
|
+
error = result.get("error", "Unknown error")
|
|
1248
|
+
self.app.notify(f"Sync failed: {error}", title="Sync", severity="error")
|