aline-ai 0.6.6__py3-none-any.whl → 0.7.0__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.
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import json as _json
6
7
  import re
7
8
  import shlex
8
9
  from pathlib import Path
@@ -163,9 +164,12 @@ class AgentsPanel(Container, can_focus=True):
163
164
  super().__init__()
164
165
  self._refresh_lock = asyncio.Lock()
165
166
  self._agents: list[dict] = []
167
+ self._rendered_fingerprint: str = ""
166
168
  self._refresh_worker: Optional[Worker] = None
167
169
  self._share_worker: Optional[Worker] = None
170
+ self._sync_worker: Optional[Worker] = None
168
171
  self._share_agent_id: Optional[str] = None
172
+ self._sync_agent_id: Optional[str] = None
169
173
  self._refresh_timer = None
170
174
 
171
175
  def compose(self) -> ComposeResult:
@@ -176,7 +180,7 @@ class AgentsPanel(Container, can_focus=True):
176
180
 
177
181
  def on_show(self) -> None:
178
182
  if self._refresh_timer is None:
179
- self._refresh_timer = self.set_interval(30.0, self._on_refresh_timer)
183
+ self._refresh_timer = self.set_interval(1.0, self._on_refresh_timer)
180
184
  else:
181
185
  try:
182
186
  self._refresh_timer.resume()
@@ -216,18 +220,27 @@ class AgentsPanel(Container, can_focus=True):
216
220
  agent_infos = db.list_agent_info()
217
221
  active_terminals = db.list_agents(status="active", limit=1000)
218
222
 
219
- # Get tmux windows to retrieve session_id (same as Terminal panel)
223
+ # Latest window links per terminal (V23)
224
+ 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}
226
+
227
+ # Get tmux windows to retrieve window id and fallback session_id
220
228
  tmux_windows = tmux_manager.list_inner_windows()
221
- # Map terminal_id -> tmux window
222
229
  terminal_to_window = {
223
230
  w.terminal_id: w for w in tmux_windows if w.terminal_id
224
231
  }
225
232
 
226
- # Collect all session_ids from tmux windows for title lookup
227
- session_ids = [
228
- w.session_id for w in tmux_windows if w.session_id and w.terminal_id
229
- ]
230
- # Fetch titles from database (same method as Terminal panel)
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
240
+ window = terminal_to_window.get(t.id)
241
+ if window and window.session_id:
242
+ session_ids.append(window.session_id)
243
+
231
244
  titles = self._fetch_session_titles(session_ids)
232
245
 
233
246
  # Map agent_info.id -> list of terminals
@@ -236,16 +249,21 @@ class AgentsPanel(Container, can_focus=True):
236
249
  # Find which agent_info this terminal belongs to
237
250
  agent_info_id = None
238
251
 
252
+ link = link_by_terminal.get(t.id)
253
+
239
254
  # Method 1: Check source field for "agent:{agent_info_id}" format
240
255
  source = t.source or ""
241
256
  if source.startswith("agent:"):
242
- agent_info_id = source[6:] # Extract agent_info_id after "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
243
262
 
244
- # Method 2: Fallback - check tmux window's session.agent_id
263
+ # Method 3: Fallback - check tmux window's session.agent_id
245
264
  if not agent_info_id:
246
265
  window = terminal_to_window.get(t.id)
247
266
  if window and window.session_id:
248
- # Look up session to get agent_id
249
267
  session = db.get_session_by_id(window.session_id)
250
268
  if session:
251
269
  agent_info_id = session.agent_id
@@ -254,16 +272,18 @@ class AgentsPanel(Container, can_focus=True):
254
272
  if agent_info_id not in agent_to_terminals:
255
273
  agent_to_terminals[agent_info_id] = []
256
274
 
257
- # Get session_id and title from tmux window (same as Terminal panel)
275
+ # Get session_id from windowlink (preferred) or tmux window
258
276
  window = terminal_to_window.get(t.id)
259
- session_id = window.session_id if window else None
277
+ session_id = (
278
+ link.session_id if link and link.session_id else (window.session_id if window else None)
279
+ )
260
280
  title = titles.get(session_id, "") if session_id else ""
261
281
 
262
282
  agent_to_terminals[agent_info_id].append(
263
283
  {
264
284
  "terminal_id": t.id,
265
285
  "session_id": session_id,
266
- "provider": t.provider or "",
286
+ "provider": link.provider if link and link.provider else (t.provider or ""),
267
287
  "session_type": t.session_type or "",
268
288
  "title": title,
269
289
  "cwd": t.cwd or "",
@@ -278,12 +298,23 @@ class AgentsPanel(Container, can_focus=True):
278
298
  "name": info.name,
279
299
  "description": info.description or "",
280
300
  "terminals": terminals,
301
+ "share_url": getattr(info, "share_url", None),
302
+ "last_synced_at": getattr(info, "last_synced_at", None),
281
303
  }
282
304
  )
283
305
  except Exception as e:
284
306
  logger.debug(f"Failed to collect agents: {e}")
285
307
  return agents
286
308
 
309
+
310
+ @staticmethod
311
+ def _fingerprint(agents: list[dict]) -> str:
312
+ """Fast serialisation used only for change detection."""
313
+ try:
314
+ return _json.dumps(agents, sort_keys=True, default=str)
315
+ except Exception:
316
+ return ""
317
+
287
318
  def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
288
319
  # Handle refresh worker
289
320
  if self._refresh_worker is not None and event.worker is self._refresh_worker:
@@ -293,6 +324,10 @@ class AgentsPanel(Container, can_focus=True):
293
324
  self._agents = self._refresh_worker.result or []
294
325
  else:
295
326
  return
327
+ fp = self._fingerprint(self._agents)
328
+ if fp == self._rendered_fingerprint:
329
+ return # nothing changed – skip re-render to avoid flicker
330
+ self._rendered_fingerprint = fp
296
331
  self.run_worker(
297
332
  self._render_agents(), group="agents-render", exclusive=True
298
333
  )
@@ -302,6 +337,10 @@ class AgentsPanel(Container, can_focus=True):
302
337
  if self._share_worker is not None and event.worker is self._share_worker:
303
338
  self._handle_share_worker_state_changed(event)
304
339
 
340
+ # Handle sync worker
341
+ if self._sync_worker is not None and event.worker is self._sync_worker:
342
+ self._handle_sync_worker_state_changed(event)
343
+
305
344
  async def _render_agents(self) -> None:
306
345
  async with self._refresh_lock:
307
346
  try:
@@ -339,15 +378,25 @@ class AgentsPanel(Container, can_focus=True):
339
378
  )
340
379
  )
341
380
 
342
- # Share button
343
- await row.mount(
344
- Button(
345
- "Share",
346
- id=f"share-{safe_id}",
347
- name=agent["id"],
348
- classes="agent-share",
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",
389
+ )
390
+ )
391
+ else:
392
+ await row.mount(
393
+ Button(
394
+ "Share",
395
+ id=f"share-{safe_id}",
396
+ name=agent["id"],
397
+ classes="agent-share",
398
+ )
349
399
  )
350
- )
351
400
 
352
401
  # Create terminal button
353
402
  await row.mount(
@@ -500,6 +549,11 @@ class AgentsPanel(Container, can_focus=True):
500
549
  await self._share_agent(agent_id)
501
550
  return
502
551
 
552
+ if btn_id.startswith("sync-"):
553
+ agent_id = event.button.name or ""
554
+ await self._sync_agent(agent_id)
555
+ return
556
+
503
557
  if btn_id.startswith("switch-"):
504
558
  terminal_id = event.button.name or ""
505
559
  await self._switch_to_terminal(terminal_id)
@@ -543,7 +597,13 @@ class AgentsPanel(Container, can_focus=True):
543
597
 
544
598
  def _on_create_result(self, result: dict | None) -> None:
545
599
  if result:
546
- self.app.notify(f"Created: {result.get('name')}", title="Agent")
600
+ if result.get("imported"):
601
+ n = result.get("sessions_imported", 0)
602
+ self.app.notify(
603
+ f"Imported: {result.get('name')} ({n} sessions)", title="Agent"
604
+ )
605
+ else:
606
+ self.app.notify(f"Created: {result.get('name')}", title="Agent")
547
607
  self.refresh_data()
548
608
 
549
609
  async def _create_terminal_for_agent(self, agent_id: str) -> None:
@@ -619,7 +679,7 @@ class AgentsPanel(Container, can_focus=True):
619
679
  try:
620
680
  from ...codex_home import prepare_codex_home
621
681
 
622
- codex_home = prepare_codex_home(terminal_id, agent_id=agent_id)
682
+ codex_home = prepare_codex_home(terminal_id)
623
683
  except Exception:
624
684
  codex_home = None
625
685
 
@@ -1127,3 +1187,76 @@ class AgentsPanel(Container, can_focus=True):
1127
1187
  self.app.notify(
1128
1188
  f"Share failed (exit {exit_code}){suffix}", title="Share", timeout=6
1129
1189
  )
1190
+
1191
+ async def _sync_agent(self, agent_id: str) -> None:
1192
+ """Sync all sessions for an agent with remote share."""
1193
+ if not agent_id:
1194
+ return
1195
+
1196
+ # Check if sync is already in progress
1197
+ if self._sync_worker is not None and self._sync_worker.state in (
1198
+ WorkerState.PENDING,
1199
+ WorkerState.RUNNING,
1200
+ ):
1201
+ return
1202
+
1203
+ self._sync_agent_id = agent_id
1204
+
1205
+ app = self.app
1206
+
1207
+ def progress_callback(message: str) -> None:
1208
+ try:
1209
+ app.call_from_thread(app.notify, message, title="Sync", timeout=3)
1210
+ except Exception:
1211
+ pass
1212
+
1213
+ def work() -> dict:
1214
+ from ...commands.sync_agent import sync_agent_command
1215
+
1216
+ return sync_agent_command(
1217
+ agent_id=agent_id,
1218
+ progress_callback=progress_callback,
1219
+ )
1220
+
1221
+ self.app.notify("Starting sync...", title="Sync", timeout=2)
1222
+ self._sync_worker = self.run_worker(work, thread=True, exit_on_error=False)
1223
+
1224
+ def _handle_sync_worker_state_changed(self, event: Worker.StateChanged) -> None:
1225
+ """Handle sync worker state changes."""
1226
+ if event.state == WorkerState.ERROR:
1227
+ err = self._sync_worker.error if self._sync_worker else "Unknown error"
1228
+ self.app.notify(f"Sync failed: {err}", title="Sync", severity="error")
1229
+ return
1230
+
1231
+ if event.state != WorkerState.SUCCESS:
1232
+ return
1233
+
1234
+ result = self._sync_worker.result if self._sync_worker else {}
1235
+
1236
+ if result.get("success"):
1237
+ pulled = result.get("sessions_pulled", 0)
1238
+ pushed = result.get("sessions_pushed", 0)
1239
+
1240
+ # Copy share URL to clipboard
1241
+ agent_id = self._sync_agent_id
1242
+ share_url = None
1243
+ if agent_id:
1244
+ agent = next((a for a in self._agents if a["id"] == agent_id), None)
1245
+ if agent:
1246
+ share_url = agent.get("share_url")
1247
+
1248
+ if share_url:
1249
+ copied = copy_text(self.app, share_url)
1250
+ suffix = " (link copied)" if copied else ""
1251
+ else:
1252
+ suffix = ""
1253
+
1254
+ self.app.notify(
1255
+ f"Synced: pulled {pulled}, pushed {pushed} session(s){suffix}",
1256
+ title="Sync",
1257
+ timeout=6,
1258
+ )
1259
+ self.refresh_data()
1260
+ else:
1261
+ error = result.get("error", "Unknown error")
1262
+ self.app.notify(f"Sync failed: {error}", title="Sync", severity="error")
realign/db/base.py CHANGED
@@ -129,7 +129,7 @@ class AgentRecord:
129
129
 
130
130
  @dataclass
131
131
  class AgentInfoRecord:
132
- """Agent profile/identity data (V20)."""
132
+ """Agent profile/identity data (V20, V22: sync fields)."""
133
133
 
134
134
  id: str
135
135
  name: str
@@ -137,6 +137,27 @@ class AgentInfoRecord:
137
137
  updated_at: datetime
138
138
  description: Optional[str] = ""
139
139
  visibility: str = "visible"
140
+ # V22: sync metadata
141
+ share_id: Optional[str] = None
142
+ share_url: Optional[str] = None
143
+ share_admin_token: Optional[str] = None
144
+ share_contributor_token: Optional[str] = None
145
+ share_expiry_at: Optional[str] = None
146
+ last_synced_at: Optional[str] = None
147
+ sync_version: int = 0
148
+
149
+
150
+ @dataclass
151
+ class WindowLinkRecord:
152
+ """Represents a terminal/session association (V23)."""
153
+
154
+ terminal_id: str
155
+ agent_id: Optional[str] = None
156
+ session_id: Optional[str] = None
157
+ provider: Optional[str] = None
158
+ source: Optional[str] = None
159
+ ts: Optional[float] = None
160
+ created_at: Optional[datetime] = None
140
161
 
141
162
 
142
163
  @dataclass
@@ -228,6 +249,27 @@ class DatabaseInterface(ABC):
228
249
  """Backfill total_turns for all sessions from turns table (V10 migration)."""
229
250
  pass
230
251
 
252
+ @abstractmethod
253
+ def insert_window_link(
254
+ self,
255
+ *,
256
+ terminal_id: str,
257
+ agent_id: Optional[str],
258
+ session_id: Optional[str],
259
+ provider: Optional[str],
260
+ source: Optional[str],
261
+ ts: Optional[float] = None,
262
+ ) -> None:
263
+ """Insert a window link record (V23)."""
264
+ pass
265
+
266
+ @abstractmethod
267
+ def list_latest_window_links(
268
+ self, *, agent_id: Optional[str] = None, limit: int = 1000
269
+ ) -> List[WindowLinkRecord]:
270
+ """List latest window link per terminal (V23)."""
271
+ pass
272
+
231
273
  @abstractmethod
232
274
  def list_sessions(
233
275
  self, limit: int = 100, workspace_path: Optional[str] = None
realign/db/schema.py CHANGED
@@ -82,9 +82,12 @@ Schema V19: Agent association for sessions.
82
82
 
83
83
  Schema V20: Agent identity/profile table.
84
84
  - agent_info table: name, description for agent profiles
85
+
86
+ Schema V23: WindowLink mapping for terminal/session association.
87
+ - windowlink table: terminal_id/agent_id/session_id with timestamp
85
88
  """
86
89
 
87
- SCHEMA_VERSION = 21
90
+ SCHEMA_VERSION = 23
88
91
 
89
92
  FTS_EVENTS_SCRIPTS = [
90
93
  # Full Text Search for Events
@@ -343,13 +346,20 @@ INIT_SCRIPTS = [
343
346
  updated_at TEXT DEFAULT (datetime('now'))
344
347
  );
345
348
  """,
346
- # Agent identity/profile table (V20)
349
+ # Agent identity/profile table (V20, V22: sync columns)
347
350
  """
348
351
  CREATE TABLE IF NOT EXISTS agent_info (
349
352
  id TEXT PRIMARY KEY,
350
353
  name TEXT NOT NULL,
351
354
  description TEXT DEFAULT '',
352
355
  visibility TEXT NOT NULL DEFAULT 'visible',
356
+ share_id TEXT,
357
+ share_url TEXT,
358
+ share_admin_token TEXT,
359
+ share_contributor_token TEXT,
360
+ share_expiry_at TEXT,
361
+ last_synced_at TEXT,
362
+ sync_version INTEGER DEFAULT 0,
353
363
  created_at TEXT DEFAULT (datetime('now')),
354
364
  updated_at TEXT DEFAULT (datetime('now'))
355
365
  );
@@ -405,6 +415,21 @@ MIGRATION_V1_TO_V2 = [
405
415
  """
406
416
  CREATE INDEX IF NOT EXISTS idx_sessions_type ON sessions(session_type);
407
417
  """,
418
+ # WindowLink table (V23)
419
+ """
420
+ CREATE TABLE IF NOT EXISTS windowlink (
421
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
422
+ terminal_id TEXT NOT NULL,
423
+ agent_id TEXT,
424
+ session_id TEXT,
425
+ provider TEXT,
426
+ source TEXT,
427
+ ts REAL,
428
+ created_at TEXT DEFAULT (datetime('now'))
429
+ );
430
+ """,
431
+ "CREATE INDEX IF NOT EXISTS idx_windowlink_terminal_ts ON windowlink(terminal_id, ts DESC);",
432
+ "CREATE INDEX IF NOT EXISTS idx_windowlink_agent_ts ON windowlink(agent_id, ts DESC);",
408
433
  ]
409
434
 
410
435
  # Migration scripts from V2 to V3
@@ -693,6 +718,33 @@ MIGRATION_V20_TO_V21 = [
693
718
  "ALTER TABLE agent_info ADD COLUMN visibility TEXT NOT NULL DEFAULT 'visible';",
694
719
  ]
695
720
 
721
+ MIGRATION_V21_TO_V22 = [
722
+ "ALTER TABLE agent_info ADD COLUMN share_id TEXT;",
723
+ "ALTER TABLE agent_info ADD COLUMN share_url TEXT;",
724
+ "ALTER TABLE agent_info ADD COLUMN share_admin_token TEXT;",
725
+ "ALTER TABLE agent_info ADD COLUMN share_contributor_token TEXT;",
726
+ "ALTER TABLE agent_info ADD COLUMN share_expiry_at TEXT;",
727
+ "ALTER TABLE agent_info ADD COLUMN last_synced_at TEXT;",
728
+ "ALTER TABLE agent_info ADD COLUMN sync_version INTEGER DEFAULT 0;",
729
+ ]
730
+
731
+ MIGRATION_V22_TO_V23 = [
732
+ """
733
+ CREATE TABLE IF NOT EXISTS windowlink (
734
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
735
+ terminal_id TEXT NOT NULL,
736
+ agent_id TEXT,
737
+ session_id TEXT,
738
+ provider TEXT,
739
+ source TEXT,
740
+ ts REAL,
741
+ created_at TEXT DEFAULT (datetime('now'))
742
+ );
743
+ """,
744
+ "CREATE INDEX IF NOT EXISTS idx_windowlink_terminal_ts ON windowlink(terminal_id, ts DESC);",
745
+ "CREATE INDEX IF NOT EXISTS idx_windowlink_agent_ts ON windowlink(agent_id, ts DESC);",
746
+ ]
747
+
696
748
 
697
749
  def get_migration_scripts(from_version: int, to_version: int) -> list:
698
750
  """Get migration scripts for upgrading between versions."""
@@ -767,4 +819,10 @@ def get_migration_scripts(from_version: int, to_version: int) -> list:
767
819
  if from_version < 21 and to_version >= 21:
768
820
  scripts.extend(MIGRATION_V20_TO_V21)
769
821
 
822
+ if from_version < 22 and to_version >= 22:
823
+ scripts.extend(MIGRATION_V21_TO_V22)
824
+
825
+ if from_version < 23 and to_version >= 23:
826
+ scripts.extend(MIGRATION_V22_TO_V23)
827
+
770
828
  return scripts
realign/db/sqlite_db.py CHANGED
@@ -23,6 +23,7 @@ from .base import (
23
23
  AgentRecord,
24
24
  AgentInfoRecord,
25
25
  AgentContextRecord,
26
+ WindowLinkRecord,
26
27
  UserRecord,
27
28
  )
28
29
  from .schema import (
@@ -2395,6 +2396,92 @@ class SQLiteDatabase(DatabaseInterface):
2395
2396
  except sqlite3.OperationalError:
2396
2397
  return []
2397
2398
 
2399
+ def insert_window_link(
2400
+ self,
2401
+ *,
2402
+ terminal_id: str,
2403
+ agent_id: Optional[str],
2404
+ session_id: Optional[str],
2405
+ provider: Optional[str],
2406
+ source: Optional[str],
2407
+ ts: Optional[float] = None,
2408
+ ) -> None:
2409
+ conn = self._get_connection()
2410
+ cursor = conn.cursor()
2411
+ ts_value = ts if ts is not None else time.time()
2412
+ try:
2413
+ cursor.execute(
2414
+ """
2415
+ INSERT INTO windowlink (
2416
+ terminal_id, agent_id, session_id, provider, source, ts
2417
+ ) VALUES (?, ?, ?, ?, ?, ?)
2418
+ """,
2419
+ (
2420
+ terminal_id,
2421
+ agent_id,
2422
+ session_id,
2423
+ provider,
2424
+ source,
2425
+ float(ts_value),
2426
+ ),
2427
+ )
2428
+ conn.commit()
2429
+ except sqlite3.OperationalError:
2430
+ conn.rollback()
2431
+
2432
+ def list_latest_window_links(
2433
+ self, *, agent_id: Optional[str] = None, limit: int = 1000
2434
+ ) -> List[WindowLinkRecord]:
2435
+ conn = self._get_connection()
2436
+ cursor = conn.cursor()
2437
+ params: List[Any] = []
2438
+ where_clause = ""
2439
+ if agent_id:
2440
+ where_clause = "WHERE agent_id = ?"
2441
+ params.append(agent_id)
2442
+ params.append(limit)
2443
+ try:
2444
+ cursor.execute(
2445
+ f"""
2446
+ SELECT terminal_id, agent_id, session_id, provider, source, ts, created_at
2447
+ FROM (
2448
+ SELECT terminal_id,
2449
+ agent_id,
2450
+ session_id,
2451
+ provider,
2452
+ source,
2453
+ ts,
2454
+ created_at,
2455
+ ROW_NUMBER() OVER (
2456
+ PARTITION BY terminal_id
2457
+ ORDER BY ts DESC, id DESC
2458
+ ) AS rn
2459
+ FROM windowlink
2460
+ {where_clause}
2461
+ ) AS ranked
2462
+ WHERE rn = 1
2463
+ LIMIT ?
2464
+ """,
2465
+ params,
2466
+ )
2467
+ rows = cursor.fetchall()
2468
+ out: List[WindowLinkRecord] = []
2469
+ for row in rows:
2470
+ out.append(
2471
+ WindowLinkRecord(
2472
+ terminal_id=row[0],
2473
+ agent_id=row[1],
2474
+ session_id=row[2],
2475
+ provider=row[3],
2476
+ source=row[4],
2477
+ ts=row[5],
2478
+ created_at=self._parse_datetime(row[6]) if row[6] else None,
2479
+ )
2480
+ )
2481
+ return out
2482
+ except sqlite3.OperationalError:
2483
+ return []
2484
+
2398
2485
  def delete_agent(self, agent_id: str) -> bool:
2399
2486
  """Delete an agent by ID."""
2400
2487
  conn = self._get_connection()
@@ -3081,12 +3168,20 @@ class SQLiteDatabase(DatabaseInterface):
3081
3168
 
3082
3169
  def _row_to_agent_info(self, row: sqlite3.Row) -> AgentInfoRecord:
3083
3170
  """Convert a database row to an AgentInfoRecord."""
3171
+ keys = row.keys()
3084
3172
  visibility = "visible"
3085
3173
  try:
3086
- if "visibility" in row.keys():
3174
+ if "visibility" in keys:
3087
3175
  visibility = row["visibility"] or "visible"
3088
3176
  except Exception:
3089
3177
  visibility = "visible"
3178
+
3179
+ def _safe_get(key: str, default=None):
3180
+ try:
3181
+ return row[key] if key in keys else default
3182
+ except Exception:
3183
+ return default
3184
+
3090
3185
  return AgentInfoRecord(
3091
3186
  id=row["id"],
3092
3187
  name=row["name"],
@@ -3094,6 +3189,13 @@ class SQLiteDatabase(DatabaseInterface):
3094
3189
  created_at=self._parse_datetime(row["created_at"]),
3095
3190
  updated_at=self._parse_datetime(row["updated_at"]),
3096
3191
  visibility=visibility,
3192
+ share_id=_safe_get("share_id"),
3193
+ share_url=_safe_get("share_url"),
3194
+ share_admin_token=_safe_get("share_admin_token"),
3195
+ share_contributor_token=_safe_get("share_contributor_token"),
3196
+ share_expiry_at=_safe_get("share_expiry_at"),
3197
+ last_synced_at=_safe_get("last_synced_at"),
3198
+ sync_version=_safe_get("sync_version", 0) or 0,
3097
3199
  )
3098
3200
 
3099
3201
  def get_or_create_agent_info(
@@ -3198,3 +3300,76 @@ class SQLiteDatabase(DatabaseInterface):
3198
3300
  return [self._row_to_agent_info(row) for row in cursor.fetchall()]
3199
3301
  except sqlite3.OperationalError:
3200
3302
  return []
3303
+
3304
+ def update_agent_sync_metadata(
3305
+ self,
3306
+ agent_id: str,
3307
+ *,
3308
+ share_id: Optional[str] = None,
3309
+ share_url: Optional[str] = None,
3310
+ share_admin_token: Optional[str] = None,
3311
+ share_contributor_token: Optional[str] = None,
3312
+ share_expiry_at: Optional[str] = None,
3313
+ last_synced_at: Optional[str] = None,
3314
+ sync_version: Optional[int] = None,
3315
+ ) -> Optional[AgentInfoRecord]:
3316
+ """Update sync-related metadata on an agent_info record."""
3317
+ conn = self._get_connection()
3318
+ sets: list[str] = []
3319
+ params: list[Any] = []
3320
+
3321
+ if share_id is not None:
3322
+ sets.append("share_id = ?")
3323
+ params.append(share_id)
3324
+ if share_url is not None:
3325
+ sets.append("share_url = ?")
3326
+ params.append(share_url)
3327
+ if share_admin_token is not None:
3328
+ sets.append("share_admin_token = ?")
3329
+ params.append(share_admin_token)
3330
+ if share_contributor_token is not None:
3331
+ sets.append("share_contributor_token = ?")
3332
+ params.append(share_contributor_token)
3333
+ if share_expiry_at is not None:
3334
+ sets.append("share_expiry_at = ?")
3335
+ params.append(share_expiry_at)
3336
+ if last_synced_at is not None:
3337
+ sets.append("last_synced_at = ?")
3338
+ params.append(last_synced_at)
3339
+ if sync_version is not None:
3340
+ sets.append("sync_version = ?")
3341
+ params.append(sync_version)
3342
+
3343
+ if not sets:
3344
+ return self.get_agent_info(agent_id)
3345
+
3346
+ sets.append("updated_at = datetime('now')")
3347
+ params.append(agent_id)
3348
+
3349
+ try:
3350
+ conn.execute(
3351
+ f"UPDATE agent_info SET {', '.join(sets)} WHERE id = ?",
3352
+ params,
3353
+ )
3354
+ conn.commit()
3355
+ except sqlite3.OperationalError:
3356
+ return None
3357
+
3358
+ return self.get_agent_info(agent_id)
3359
+
3360
+ def get_agent_content_hashes(self, agent_id: str) -> set[str]:
3361
+ """Return all content_hash values for turns in this agent's sessions."""
3362
+ conn = self._get_connection()
3363
+ try:
3364
+ cursor = conn.execute(
3365
+ """
3366
+ SELECT DISTINCT t.content_hash
3367
+ FROM turns t
3368
+ JOIN sessions s ON t.session_id = s.id
3369
+ WHERE s.agent_id = ?
3370
+ """,
3371
+ (agent_id,),
3372
+ )
3373
+ return {row["content_hash"] for row in cursor.fetchall()}
3374
+ except sqlite3.OperationalError:
3375
+ return set()