aline-ai 0.7.1__py3-none-any.whl → 0.7.3__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.
@@ -6,6 +6,7 @@ import asyncio
6
6
  import json as _json
7
7
  import re
8
8
  import shlex
9
+ import time
9
10
  from pathlib import Path
10
11
  from typing import Optional
11
12
 
@@ -171,6 +172,7 @@ class AgentsPanel(Container, can_focus=True):
171
172
  self._share_agent_id: Optional[str] = None
172
173
  self._sync_agent_id: Optional[str] = None
173
174
  self._refresh_timer = None
175
+ self._last_refresh_error_at: float | None = None
174
176
 
175
177
  def compose(self) -> ComposeResult:
176
178
  with Horizontal(classes="summary"):
@@ -180,7 +182,8 @@ class AgentsPanel(Container, can_focus=True):
180
182
 
181
183
  def on_show(self) -> None:
182
184
  if self._refresh_timer is None:
183
- self._refresh_timer = self.set_interval(1.0, self._on_refresh_timer)
185
+ # Refresh frequently, but avoid hammering SQLite/tmux (can cause transient empty UI).
186
+ self._refresh_timer = self.set_interval(2.0, self._on_refresh_timer)
184
187
  else:
185
188
  try:
186
189
  self._refresh_timer.resume()
@@ -212,100 +215,127 @@ class AgentsPanel(Container, can_focus=True):
212
215
 
213
216
  def _collect_agents(self) -> list[dict]:
214
217
  """Collect agent info with their terminals."""
215
- agents = []
216
- try:
217
- from ...db import get_database
218
+ agents: list[dict] = []
218
219
 
219
- db = get_database(read_only=True)
220
- agent_infos = db.list_agent_info()
220
+ from ...db import get_database
221
+
222
+ # Dashboard should prefer correctness/stability over ultra-low lock timeouts.
223
+ db = get_database(read_only=True, connect_timeout_seconds=2.0)
224
+
225
+ # Critical: if this fails, let it surface as a worker ERROR so we can keep
226
+ # the last rendered UI instead of flashing an empty agent list.
227
+ agent_infos = db.list_agent_info()
228
+
229
+ # Best-effort: missing pieces should degrade gracefully (names still render).
230
+ try:
221
231
  active_terminals = db.list_agents(status="active", limit=1000)
232
+ except Exception as e:
233
+ logger.debug(f"Failed to list active terminals: {e}")
234
+ active_terminals = []
222
235
 
223
- # Latest window links per terminal (V23)
236
+ try:
224
237
  latest_links = db.list_latest_window_links(limit=2000)
225
- link_by_terminal = {l.terminal_id: l for l in latest_links if l.terminal_id}
238
+ except Exception:
239
+ latest_links = []
240
+ link_by_terminal = {
241
+ l.terminal_id: l for l in latest_links if getattr(l, "terminal_id", None)
242
+ }
226
243
 
227
- # Get tmux windows to retrieve window id and fallback session_id
244
+ try:
228
245
  tmux_windows = tmux_manager.list_inner_windows()
229
- terminal_to_window = {
230
- w.terminal_id: w for w in tmux_windows if w.terminal_id
231
- }
246
+ except Exception as e:
247
+ logger.debug(f"Failed to list tmux windows: {e}")
248
+ tmux_windows = []
249
+ terminal_to_window = {
250
+ w.terminal_id: w for w in tmux_windows if getattr(w, "terminal_id", None)
251
+ }
232
252
 
233
- # Collect all session_ids for title lookup
234
- session_ids: list[str] = []
235
- for t in active_terminals:
236
- link = link_by_terminal.get(t.id)
237
- if link and link.session_id:
238
- session_ids.append(link.session_id)
239
- continue
253
+ # Collect all session_ids for title lookup
254
+ session_ids: list[str] = []
255
+ for t in active_terminals:
256
+ link = link_by_terminal.get(t.id)
257
+ if link and getattr(link, "session_id", None):
258
+ session_ids.append(link.session_id)
259
+ continue
260
+ window = terminal_to_window.get(t.id)
261
+ if window and getattr(window, "session_id", None):
262
+ session_ids.append(window.session_id)
263
+
264
+ titles = self._fetch_session_titles(session_ids)
265
+
266
+ # Map agent_info.id -> list of terminals
267
+ agent_to_terminals: dict[str, list[dict]] = {}
268
+ for t in active_terminals:
269
+ # Find which agent_info this terminal belongs to
270
+ agent_info_id = None
271
+
272
+ link = link_by_terminal.get(t.id)
273
+
274
+ # Method 1: Check source field for "agent:{agent_info_id}" format
275
+ source = t.source or ""
276
+ if source.startswith("agent:"):
277
+ agent_info_id = source[6:]
278
+
279
+ # Method 2: WindowLink agent_id
280
+ if not agent_info_id and link and getattr(link, "agent_id", None):
281
+ agent_info_id = link.agent_id
282
+
283
+ # Method 3: Fallback - check tmux window's session.agent_id
284
+ if not agent_info_id:
240
285
  window = terminal_to_window.get(t.id)
241
- if window and window.session_id:
242
- session_ids.append(window.session_id)
243
-
244
- titles = self._fetch_session_titles(session_ids)
245
-
246
- # Map agent_info.id -> list of terminals
247
- agent_to_terminals: dict[str, list[dict]] = {}
248
- for t in active_terminals:
249
- # Find which agent_info this terminal belongs to
250
- agent_info_id = None
251
-
252
- link = link_by_terminal.get(t.id)
253
-
254
- # Method 1: Check source field for "agent:{agent_info_id}" format
255
- source = t.source or ""
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:
286
+ if window and getattr(window, "session_id", None):
287
+ try:
267
288
  session = db.get_session_by_id(window.session_id)
268
- if session:
269
- agent_info_id = session.agent_id
289
+ except Exception:
290
+ session = None
291
+ if session:
292
+ agent_info_id = session.agent_id
270
293
 
271
- if agent_info_id:
272
- if agent_info_id not in agent_to_terminals:
273
- agent_to_terminals[agent_info_id] = []
294
+ if agent_info_id:
295
+ agent_to_terminals.setdefault(agent_info_id, [])
274
296
 
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
- }
297
+ # Get session_id from windowlink (preferred) or tmux window
298
+ window = terminal_to_window.get(t.id)
299
+ session_id = (
300
+ link.session_id
301
+ if link and getattr(link, "session_id", None)
302
+ else (
303
+ window.session_id
304
+ if window and getattr(window, "session_id", None)
305
+ else None
291
306
  )
307
+ )
308
+ title = titles.get(session_id, "") if session_id else ""
292
309
 
293
- for info in agent_infos:
294
- terminals = agent_to_terminals.get(info.id, [])
295
- agents.append(
310
+ agent_to_terminals[agent_info_id].append(
296
311
  {
297
- "id": info.id,
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),
312
+ "terminal_id": t.id,
313
+ "session_id": session_id,
314
+ "provider": (
315
+ link.provider
316
+ if link and getattr(link, "provider", None)
317
+ else (t.provider or "")
318
+ ),
319
+ "session_type": t.session_type or "",
320
+ "title": title,
321
+ "cwd": t.cwd or "",
303
322
  }
304
323
  )
305
- except Exception as e:
306
- logger.debug(f"Failed to collect agents: {e}")
307
- return agents
308
324
 
325
+ for info in agent_infos:
326
+ terminals = agent_to_terminals.get(info.id, [])
327
+ agents.append(
328
+ {
329
+ "id": info.id,
330
+ "name": info.name,
331
+ "description": info.description or "",
332
+ "terminals": terminals,
333
+ "share_url": getattr(info, "share_url", None),
334
+ "last_synced_at": getattr(info, "last_synced_at", None),
335
+ }
336
+ )
337
+
338
+ return agents
309
339
 
310
340
  @staticmethod
311
341
  def _fingerprint(agents: list[dict]) -> str:
@@ -319,9 +349,21 @@ class AgentsPanel(Container, can_focus=True):
319
349
  # Handle refresh worker
320
350
  if self._refresh_worker is not None and event.worker is self._refresh_worker:
321
351
  if event.state == WorkerState.ERROR:
322
- self._agents = []
352
+ # Keep the last successfully-rendered list on refresh errors to avoid
353
+ # flashing an empty Agents tab during transient tmux/SQLite hiccups.
354
+ self._last_refresh_error_at = time.monotonic()
355
+ err = self._refresh_worker.error
356
+ if isinstance(err, BaseException):
357
+ logger.warning(
358
+ "Agents refresh failed",
359
+ exc_info=(type(err), err, err.__traceback__),
360
+ )
361
+ else:
362
+ logger.warning(f"Agents refresh failed: {err}")
363
+ return
323
364
  elif event.state == WorkerState.SUCCESS:
324
365
  self._agents = self._refresh_worker.result or []
366
+ self._last_refresh_error_at = None
325
367
  else:
326
368
  return
327
369
  fp = self._fingerprint(self._agents)
@@ -329,7 +371,10 @@ class AgentsPanel(Container, can_focus=True):
329
371
  return # nothing changed – skip re-render to avoid flicker
330
372
  self._rendered_fingerprint = fp
331
373
  self.run_worker(
332
- self._render_agents(), group="agents-render", exclusive=True
374
+ self._render_agents(),
375
+ group="agents-render",
376
+ exclusive=True,
377
+ exit_on_error=False,
333
378
  )
334
379
  return
335
380
 
@@ -345,108 +390,123 @@ class AgentsPanel(Container, can_focus=True):
345
390
  async with self._refresh_lock:
346
391
  try:
347
392
  container = self.query_one("#agents-list", Vertical)
348
- except Exception:
349
- return
393
+ await container.remove_children()
350
394
 
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
395
+ if not self._agents:
396
+ await container.mount(Static("No agents yet. Click 'Create Agent' to add one."))
397
+ return
358
398
 
359
- for agent in self._agents:
360
- safe_id = self._safe_id(agent["id"])
399
+ for agent in self._agents:
400
+ safe_id = self._safe_id(agent["id"])
361
401
 
362
- # Agent row with name, create button, and delete button
363
- row = Horizontal(classes="agent-row")
364
- await container.mount(row)
402
+ # Agent row with name, create button, and delete button
403
+ row = Horizontal(classes="agent-row")
404
+ await container.mount(row)
365
405
 
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")
406
+ # Agent name button
407
+ name_label = Text(agent["name"], style="bold")
408
+ terminal_count = len(agent["terminals"])
409
+ if terminal_count > 0:
410
+ name_label.append(f" ({terminal_count})", style="dim")
371
411
 
372
- await row.mount(
373
- AgentNameButton(
412
+ agent_btn = AgentNameButton(
374
413
  name_label,
375
414
  id=f"agent-{safe_id}",
376
415
  name=agent["id"],
377
416
  classes="agent-name",
378
417
  )
379
- )
418
+ if agent.get("description"):
419
+ agent_btn.tooltip = agent["description"]
420
+ await row.mount(agent_btn)
380
421
 
381
- # Share or Sync button (Sync if agent already has a share_url)
382
- if agent.get("share_url"):
422
+ # Share or Sync button (Sync if agent already has a share_url)
423
+ if agent.get("share_url"):
424
+ await row.mount(
425
+ Button(
426
+ "Sync",
427
+ id=f"sync-{safe_id}",
428
+ name=agent["id"],
429
+ classes="agent-share",
430
+ )
431
+ )
432
+ await row.mount(
433
+ Button(
434
+ "Link",
435
+ id=f"link-{safe_id}",
436
+ name=agent["id"],
437
+ classes="agent-share",
438
+ )
439
+ )
440
+ else:
441
+ await row.mount(
442
+ Button(
443
+ "Share",
444
+ id=f"share-{safe_id}",
445
+ name=agent["id"],
446
+ classes="agent-share",
447
+ )
448
+ )
449
+
450
+ # Create terminal button
383
451
  await row.mount(
384
452
  Button(
385
- "Sync",
386
- id=f"sync-{safe_id}",
453
+ "+ Term",
454
+ id=f"create-term-{safe_id}",
387
455
  name=agent["id"],
388
- classes="agent-share",
456
+ classes="agent-create",
389
457
  )
390
458
  )
391
- else:
459
+
460
+ # Delete agent button
392
461
  await row.mount(
393
462
  Button(
394
- "Share",
395
- id=f"share-{safe_id}",
463
+ "",
464
+ id=f"delete-{safe_id}",
396
465
  name=agent["id"],
397
- classes="agent-share",
466
+ variant="error",
467
+ classes="agent-delete",
398
468
  )
399
469
  )
400
470
 
401
- # Create terminal button
402
- await row.mount(
403
- Button(
404
- "+ Term",
405
- id=f"create-term-{safe_id}",
406
- name=agent["id"],
407
- classes="agent-create",
408
- )
409
- )
410
-
411
- # Delete agent button
412
- await row.mount(
413
- Button(
414
- "✕",
415
- id=f"delete-{safe_id}",
416
- name=agent["id"],
417
- variant="error",
418
- classes="agent-delete",
419
- )
420
- )
421
-
422
- # Terminal list (indented under agent)
423
- if agent["terminals"]:
424
- term_list = Vertical(classes="terminal-list")
425
- await container.mount(term_list)
426
-
427
- for term in agent["terminals"]:
428
- term_safe_id = self._safe_id(term["terminal_id"])
429
- term_row = Horizontal(classes="terminal-row")
430
- await term_list.mount(term_row)
431
-
432
- label = self._make_terminal_label(term)
433
- await term_row.mount(
434
- Button(
435
- label,
436
- id=f"switch-{term_safe_id}",
437
- name=term["terminal_id"],
438
- classes="terminal-switch",
471
+ # Terminal list (indented under agent)
472
+ if agent["terminals"]:
473
+ term_list = Vertical(classes="terminal-list")
474
+ await container.mount(term_list)
475
+
476
+ for term in agent["terminals"]:
477
+ term_safe_id = self._safe_id(term["terminal_id"])
478
+ term_row = Horizontal(classes="terminal-row")
479
+ await term_list.mount(term_row)
480
+
481
+ label = self._make_terminal_label(term)
482
+ await term_row.mount(
483
+ Button(
484
+ label,
485
+ id=f"switch-{term_safe_id}",
486
+ name=term["terminal_id"],
487
+ classes="terminal-switch",
488
+ )
439
489
  )
440
- )
441
- await term_row.mount(
442
- Button(
443
- "",
444
- id=f"close-{term_safe_id}",
445
- name=term["terminal_id"],
446
- variant="error",
447
- classes="terminal-close",
490
+ await term_row.mount(
491
+ Button(
492
+ "✕",
493
+ id=f"close-{term_safe_id}",
494
+ name=term["terminal_id"],
495
+ variant="error",
496
+ classes="terminal-close",
497
+ )
448
498
  )
449
- )
499
+ except Exception:
500
+ logger.exception("Failed to render agents list")
501
+ try:
502
+ container = self.query_one("#agents-list", Vertical)
503
+ await container.remove_children()
504
+ await container.mount(
505
+ Static("Agents UI error (see ~/.aline/.logs/dashboard.log)")
506
+ )
507
+ except Exception:
508
+ pass
509
+ return
450
510
 
451
511
  def _make_terminal_label(self, term: dict) -> Text:
452
512
  """Generate label for a terminal."""
@@ -554,6 +614,11 @@ class AgentsPanel(Container, can_focus=True):
554
614
  await self._sync_agent(agent_id)
555
615
  return
556
616
 
617
+ if btn_id.startswith("link-"):
618
+ agent_id = event.button.name or ""
619
+ await self._copy_share_link(agent_id)
620
+ return
621
+
557
622
  if btn_id.startswith("switch-"):
558
623
  terminal_id = event.button.name or ""
559
624
  await self._switch_to_terminal(terminal_id)
@@ -599,9 +664,7 @@ class AgentsPanel(Container, can_focus=True):
599
664
  if result:
600
665
  if result.get("imported"):
601
666
  n = result.get("sessions_imported", 0)
602
- self.app.notify(
603
- f"Imported: {result.get('name')} ({n} sessions)", title="Agent"
604
- )
667
+ self.app.notify(f"Imported: {result.get('name')} ({n} sessions)", title="Agent")
605
668
  else:
606
669
  self.app.notify(f"Created: {result.get('name')}", title="Agent")
607
670
  self.refresh_data()
@@ -639,11 +702,10 @@ class AgentsPanel(Container, can_focus=True):
639
702
 
640
703
  # Create the terminal with agent association
641
704
  self.run_worker(
642
- self._do_create_terminal(
643
- agent_type, workspace, skip_permissions, no_track, agent_id
644
- ),
705
+ self._do_create_terminal(agent_type, workspace, skip_permissions, no_track, agent_id),
645
706
  group="terminal-create",
646
707
  exclusive=True,
708
+ exit_on_error=False,
647
709
  )
648
710
 
649
711
  async def _do_create_terminal(
@@ -656,9 +718,7 @@ class AgentsPanel(Container, can_focus=True):
656
718
  ) -> None:
657
719
  """Actually create the terminal with agent association."""
658
720
  if agent_type == "claude":
659
- await self._create_claude_terminal(
660
- workspace, skip_permissions, no_track, agent_id
661
- )
721
+ await self._create_claude_terminal(workspace, skip_permissions, no_track, agent_id)
662
722
  elif agent_type == "codex":
663
723
  await self._create_codex_terminal(workspace, no_track, agent_id)
664
724
  elif agent_type == "opencode":
@@ -734,13 +794,9 @@ class AgentsPanel(Container, can_focus=True):
734
794
  except Exception:
735
795
  pass
736
796
  else:
737
- self.app.notify(
738
- "Failed to create terminal", title="Agent", severity="error"
739
- )
797
+ self.app.notify("Failed to create terminal", title="Agent", severity="error")
740
798
 
741
- async def _create_codex_terminal(
742
- self, workspace: str, no_track: bool, agent_id: str
743
- ) -> None:
799
+ async def _create_codex_terminal(self, workspace: str, no_track: bool, agent_id: str) -> None:
744
800
  """Create a Codex terminal associated with an agent."""
745
801
  try:
746
802
  from ...db import get_database
@@ -803,9 +859,7 @@ class AgentsPanel(Container, can_focus=True):
803
859
  except Exception:
804
860
  pass
805
861
 
806
- command = self._command_in_directory(
807
- tmux_manager.zsh_run_and_keep_open("codex"), workspace
808
- )
862
+ command = self._command_in_directory(tmux_manager.zsh_run_and_keep_open("codex"), workspace)
809
863
 
810
864
  created = tmux_manager.create_inner_window(
811
865
  "codex",
@@ -817,9 +871,7 @@ class AgentsPanel(Container, can_focus=True):
817
871
  )
818
872
 
819
873
  if not created:
820
- self.app.notify(
821
- "Failed to create terminal", title="Agent", severity="error"
822
- )
874
+ self.app.notify("Failed to create terminal", title="Agent", severity="error")
823
875
 
824
876
  async def _create_opencode_terminal(self, workspace: str, agent_id: str) -> None:
825
877
  """Create an Opencode terminal associated with an agent."""
@@ -878,9 +930,7 @@ class AgentsPanel(Container, can_focus=True):
878
930
  except Exception:
879
931
  pass
880
932
  else:
881
- self.app.notify(
882
- "Failed to create terminal", title="Agent", severity="error"
883
- )
933
+ self.app.notify("Failed to create terminal", title="Agent", severity="error")
884
934
 
885
935
  async def _create_zsh_terminal(self, workspace: str, agent_id: str) -> None:
886
936
  """Create a zsh terminal associated with an agent."""
@@ -937,9 +987,7 @@ class AgentsPanel(Container, can_focus=True):
937
987
  except Exception:
938
988
  pass
939
989
  else:
940
- self.app.notify(
941
- "Failed to create terminal", title="Agent", severity="error"
942
- )
990
+ self.app.notify("Failed to create terminal", title="Agent", severity="error")
943
991
 
944
992
  def _install_claude_hooks(self, workspace: str) -> None:
945
993
  """Install Claude hooks for a workspace."""
@@ -966,12 +1014,8 @@ class AgentsPanel(Container, can_focus=True):
966
1014
 
967
1015
  project_root = Path(workspace)
968
1016
  install_stop_hook(get_stop_settings_path(project_root), quiet=True)
969
- install_user_prompt_submit_hook(
970
- get_submit_settings_path(project_root), quiet=True
971
- )
972
- install_permission_request_hook(
973
- get_permission_settings_path(project_root), quiet=True
974
- )
1017
+ install_user_prompt_submit_hook(get_submit_settings_path(project_root), quiet=True)
1018
+ install_permission_request_hook(get_permission_settings_path(project_root), quiet=True)
975
1019
  except Exception:
976
1020
  pass
977
1021
 
@@ -1050,14 +1094,10 @@ class AgentsPanel(Container, can_focus=True):
1050
1094
  db = get_database(read_only=True)
1051
1095
  sessions = db.get_sessions_by_agent_id(agent_id)
1052
1096
  if not sessions:
1053
- self.app.notify(
1054
- "Agent has no sessions to share", title="Share", severity="warning"
1055
- )
1097
+ self.app.notify("Agent has no sessions to share", title="Share", severity="warning")
1056
1098
  return
1057
1099
  except Exception as e:
1058
- self.app.notify(
1059
- f"Failed to check sessions: {e}", title="Share", severity="error"
1060
- )
1100
+ self.app.notify(f"Failed to check sessions: {e}", title="Share", severity="error")
1061
1101
  return
1062
1102
 
1063
1103
  # Store agent_id for the worker callback
@@ -1114,9 +1154,7 @@ class AgentsPanel(Container, can_focus=True):
1114
1154
  try:
1115
1155
  match = re.search(r"\{.*\}", output, re.DOTALL)
1116
1156
  if match:
1117
- result["json"] = json_module.loads(
1118
- match.group(0), strict=False
1119
- )
1157
+ result["json"] = json_module.loads(match.group(0), strict=False)
1120
1158
  except Exception:
1121
1159
  result["json"] = None
1122
1160
 
@@ -1151,9 +1189,7 @@ class AgentsPanel(Container, can_focus=True):
1151
1189
  share_link = payload.get("share_link") or payload.get("share_url")
1152
1190
  if not share_link:
1153
1191
  share_link = result.get("share_link_guess")
1154
- slack_message = (
1155
- payload.get("slack_message") if isinstance(payload, dict) else None
1156
- )
1192
+ slack_message = payload.get("slack_message") if isinstance(payload, dict) else None
1157
1193
  if not slack_message:
1158
1194
  try:
1159
1195
  from ...db import get_database
@@ -1176,17 +1212,13 @@ class AgentsPanel(Container, can_focus=True):
1176
1212
  copied = copy_text(self.app, text_to_copy)
1177
1213
 
1178
1214
  suffix = " (copied)" if copied else ""
1179
- self.app.notify(
1180
- f"Share link: {share_link}{suffix}", title="Share", timeout=6
1181
- )
1215
+ self.app.notify(f"Share link: {share_link}{suffix}", title="Share", timeout=6)
1182
1216
  elif exit_code == 0:
1183
1217
  self.app.notify("Share completed", title="Share", timeout=3)
1184
1218
  else:
1185
1219
  extra = result.get("stderr") or ""
1186
1220
  suffix = f": {extra}" if extra else ""
1187
- self.app.notify(
1188
- f"Share failed (exit {exit_code}){suffix}", title="Share", timeout=6
1189
- )
1221
+ self.app.notify(f"Share failed (exit {exit_code}){suffix}", title="Share", timeout=6)
1190
1222
 
1191
1223
  async def _sync_agent(self, agent_id: str) -> None:
1192
1224
  """Sync all sessions for an agent with remote share."""
@@ -1260,3 +1292,26 @@ class AgentsPanel(Container, can_focus=True):
1260
1292
  else:
1261
1293
  error = result.get("error", "Unknown error")
1262
1294
  self.app.notify(f"Sync failed: {error}", title="Sync", severity="error")
1295
+
1296
+ async def _copy_share_link(self, agent_id: str) -> None:
1297
+ """Copy the share link for an agent to clipboard."""
1298
+ if not agent_id:
1299
+ return
1300
+
1301
+ agent = next((a for a in self._agents if a["id"] == agent_id), None)
1302
+ if not agent:
1303
+ self.app.notify("Agent not found", title="Link", severity="error")
1304
+ return
1305
+
1306
+ share_url = agent.get("share_url")
1307
+ if not share_url:
1308
+ self.app.notify("No share link available", title="Link", severity="warning")
1309
+ return
1310
+
1311
+ copied = copy_text(self.app, share_url)
1312
+ if copied:
1313
+ self.app.notify("Share link copied to clipboard", title="Link", timeout=3)
1314
+ else:
1315
+ self.app.notify(
1316
+ f"Failed to copy. Link: {share_url}", title="Link", severity="warning"
1317
+ )