aline-ai 0.6.7__py3-none-any.whl → 0.7.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aline-ai
3
- Version: 0.6.7
3
+ Version: 0.7.1
4
4
  Summary: Shared AI memory; everyone knows everything in teams
5
5
  Author: Sharemind
6
6
  License: MIT
@@ -1,9 +1,9 @@
1
- aline_ai-0.6.7.dist-info/licenses/LICENSE,sha256=H8wTqV5IF1oHw_HbBtS1PSDU8G_q81yblEIL_JfV8Vo,1077
2
- realign/__init__.py,sha256=_XHUZdw-wtPtpgm845liUkFT-VXg86ZiqDK3pdnnh9Q,1623
1
+ aline_ai-0.7.1.dist-info/licenses/LICENSE,sha256=H8wTqV5IF1oHw_HbBtS1PSDU8G_q81yblEIL_JfV8Vo,1077
2
+ realign/__init__.py,sha256=_ikWZs8Kc7Dp8xrIk27RttwL5TafxGktkWaWc0WLK68,1623
3
3
  realign/agent_names.py,sha256=H4oVJMkqg1ZYCk58vD_Jh9apaAHSFJRswa-C9SPdJxc,1171
4
4
  realign/auth.py,sha256=d_1yvCwluN5iIrdgjtuSKpOYAksDzrzNgntKacLVJrw,16583
5
5
  realign/claude_detector.py,sha256=ZLSJacMo6zzQclXByABKA70UNpstxqIv3fPGqdpA934,2792
6
- realign/cli.py,sha256=IctmQ0OTb6kLlWRFRQumdhY6-CpcpFtocdc68KiwxvM,37748
6
+ realign/cli.py,sha256=PiMUA_sFQ-K7zlIr1Ahs7St8NwcXDG3JKT_8yIqLwZI,40569
7
7
  realign/codex_detector.py,sha256=WGIClvlrFVCqJ5vR9DrKVsp1eJhOShvcaXibTHb0Nfc,6304
8
8
  realign/codex_home.py,sha256=ljkW8uCfQD4cisEJtPNQmIgaR0yEfWSyHwoVQFY-6p4,4374
9
9
  realign/codex_terminal_linker.py,sha256=L2Ha4drlZ7Sbq2jzXyxczOdUY3S5fu1gJqoI5WN9CKk,6211
@@ -16,9 +16,9 @@ realign/logging_config.py,sha256=LCAigKFhTj86PSJm4-kUl3Ag9h_GENh3x2iPnMv7qUI,487
16
16
  realign/mcp_server.py,sha256=LWiQ2qukYoNLsoV2ID2f0vF9jkJlBvB587HpM5jymgE,10193
17
17
  realign/mcp_watcher.py,sha256=aK4jWStv7CoCroS4tXFHgZ_y_-q4QDjrpWgm4DxcEj4,1260
18
18
  realign/redactor.py,sha256=Zsoi5HfYak2yPmck20JArhm-1cPSB78IdkBJiNVXfrc,17096
19
- realign/watcher_core.py,sha256=XOJarc_jjlf51Gj8ytcdEeaDUkVIq3Ow0bMbFHbKfAM,116690
19
+ realign/watcher_core.py,sha256=iXVXlDhYsoxk_BZ1rOVIfTDu6ABXZO18UeNQKVPsvzc,120374
20
20
  realign/watcher_daemon.py,sha256=OHUQ9P1LlagKJHfrf6uRnzO-zDtBRXIxt8ydMFHf5S8,3475
21
- realign/worker_core.py,sha256=IXDFvkmeboOUvWyNJ3iZ7xlfxAulPnmFlAtuuJSdgRo,12362
21
+ realign/worker_core.py,sha256=tJGETH_FIBpb899D8-JP_cOyDE63BYc8kxxmrhb83i4,13013
22
22
  realign/worker_daemon.py,sha256=X7Xyjw_u6m6KG4E84nx0HpDFw4cWMv8ja1G8btc9PiM,3957
23
23
  realign/adapters/__init__.py,sha256=alkJr7DRn_CrJecSJRjRJOHHnkz9EnZ5TnsU8n1Bb0k,719
24
24
  realign/adapters/base.py,sha256=2IdAZKGjg5gPB3YLf_8r3V4XAdbK7fHpj06GjjsYEFY,7409
@@ -37,21 +37,21 @@ realign/claude_hooks/user_prompt_submit_hook_installer.py,sha256=2xLF8yZcE7Iwib9
37
37
  realign/commands/__init__.py,sha256=WVaVT1orM2Z0PYaG3X6tkKb_t2v3n_3siCadh1qd_QA,107
38
38
  realign/commands/add.py,sha256=_Xzt9P15mwndA3JvBBVrki8tn9Cc0UP6SiLwM4RS8Nc,27232
39
39
  realign/commands/agent.py,sha256=3CS48bMn7tkdDWKRrfg7CYbhcJK4Pz40YjYMvwD7c2w,3173
40
- realign/commands/auth.py,sha256=QrPukpP-ogYEDSwztV0NOYI-HDgn5fPxlCQ1-e2n7gU,11082
40
+ realign/commands/auth.py,sha256=wcs1lUcSXxv75WcGruzyZ3kgi0xXA8W4lNnUwM4a3CI,11731
41
41
  realign/commands/config.py,sha256=nYnu_h2pk7GODcrzrV04K51D-s7v06FlRXHJ0HJ-gvU,6732
42
42
  realign/commands/context.py,sha256=pM2KfZHVkB-ou4nBhFvKSwnYliLBzwN3zerLyBAbhfE,7095
43
43
  realign/commands/doctor.py,sha256=0c1TZuA_cw1CSU0yKMVRU-18uTxdqjXKJ8lP2CTTNSQ,20656
44
44
  realign/commands/export_shares.py,sha256=b8dpVBx2HkbHVk9pSFXnErlAr0umciAOPpuxvTJyOBI,148467
45
- realign/commands/import_shares.py,sha256=qAH007WCQ6bwWP09MEJVmgJlRC8c-QicB2HYvMBqyRM,32966
45
+ realign/commands/import_shares.py,sha256=Jx_7HVSg7SrGGKLDxsf_UqoStDimw8B26uKkqNFF6t8,33071
46
46
  realign/commands/init.py,sha256=6rBr1LVIrQLbUH_UvoDhkF1qXmMh2xkjNWCYAUz5Tho,35274
47
47
  realign/commands/restore.py,sha256=s2BxQZHxQw9r12NzRVsK20KlGafy5AIoSjWMo5PcnHY,11173
48
48
  realign/commands/search.py,sha256=QlUDzRDD6ebq21LTtLe5-OZM62iwDrDqfbnXbuxfklU,27516
49
- realign/commands/sync_agent.py,sha256=XRcHN00TjfzGwTw3O_OXqb9Yj0lMFfDX0S7oizVpS6E,12454
49
+ realign/commands/sync_agent.py,sha256=VS_VU-4LdZpUbRKx51Gg0BFXPWZlnyROZAsahWsexIQ,14824
50
50
  realign/commands/upgrade.py,sha256=L3PLOUIN5qAQTbkfoVtSsIbbzEezA_xjjk9F1GMVfjw,12781
51
51
  realign/commands/watcher.py,sha256=4WTThIgr-Z5guKh_JqGDcPmerr97XiHrVaaijmckHsA,134350
52
52
  realign/commands/worker.py,sha256=jTu7Pj60nTnn7SsH3oNCNnO6zl4TIFCJVNSC1OoQ_0o,23363
53
53
  realign/dashboard/__init__.py,sha256=QZkHTsGityH8UkF8rmvA3xW7dMXNe0swEWr443qfgCM,128
54
- realign/dashboard/app.py,sha256=e257euP0gR9nA0w1susuLkG9tnYQk1IJJdgAICnIYxs,10398
54
+ realign/dashboard/app.py,sha256=IXF9CDbui4zXufRgc6Gagje7Duw5VlewUru4njbA6lQ,8243
55
55
  realign/dashboard/clipboard.py,sha256=81frq83E_urqLkwuCvtl0hiTEjavtdQn8kCi72jJWcs,1207
56
56
  realign/dashboard/layout.py,sha256=sZxmFj6QTbkois9MHTvBEMMcnaRVehCDqugdbiFx10k,9072
57
57
  realign/dashboard/terminal_backend.py,sha256=MlDfwtqhftyQK6jDNizQGFjAWIo5Bx2TDpSnP3MCZVM,3375
@@ -69,8 +69,8 @@ realign/dashboard/screens/help_screen.py,sha256=Icrcvbgyz49R2tBiu8vBZ4CLm6iYclv_
69
69
  realign/dashboard/screens/session_detail.py,sha256=TBkHqSHyMxsLB2QdZq9m1EoiH8oRVDbPrjt-a8I9sHs,9561
70
70
  realign/dashboard/screens/share_import.py,sha256=hl2x0yGVycsoUI76AmdZTAV-br3Q6191g5xHHrZ8hOA,6318
71
71
  realign/dashboard/styles/dashboard.tcss,sha256=9W5Tx0lgyGb4HU-z-Kn7gBdexIK0aPe0bkVn2k_AseM,3288
72
- realign/dashboard/widgets/__init__.py,sha256=33qjCa6WCQ7XojRiStdR73jX2xpKV_RlBqodVDQWkxs,577
73
- realign/dashboard/widgets/agents_panel.py,sha256=TtOX9RlF0CuwRTe1sXoo1xaf7ZykJA-YFmMu0-SKe2g,43299
72
+ realign/dashboard/widgets/__init__.py,sha256=dXsOnbeu_8XhP-6Bu6-R_0LNGqsSM6x7dG7FCDumpa8,460
73
+ realign/dashboard/widgets/agents_panel.py,sha256=CGs3qcHGcDDVIpDw1ERb8Jf2t-l--hSY_ufw9SZzM8E,43846
74
74
  realign/dashboard/widgets/config_panel.py,sha256=eRJRuqImQ8eJIKCEj4O8EvYxI-ht_anrcYbT5JskWyU,15972
75
75
  realign/dashboard/widgets/events_table.py,sha256=0cMvE0KdZFBZyvywv7vlt005qsR0aLQnQiMf3ZzK7RY,30218
76
76
  realign/dashboard/widgets/header.py,sha256=0HHCFXX7F3C6HII-WDwOJwWkJrajmKPWmdoMWyOkn9E,1587
@@ -104,8 +104,8 @@ realign/triggers/next_turn_trigger.py,sha256=-x80_I-WmIjXXzQHEPBykgx_GQW6oKaLDQx
104
104
  realign/triggers/registry.py,sha256=dkIjSd8Bg-hF0nxaO2Fi2K-0Zipqv6vVjc-HYSrA_fY,3656
105
105
  realign/triggers/turn_status.py,sha256=wAZEhXDAmDoX5F-ohWfSnZZ0eA6DAJ9svSPiSv_f6sg,6041
106
106
  realign/triggers/turn_summary.py,sha256=f3hEUshgv9skJ9AbfWpoYs417lsv_HK2A_vpPjgryO4,4467
107
- aline_ai-0.6.7.dist-info/METADATA,sha256=GyI08kzWpN5QDEsgynocnlk8Cp5zSTiKXRltp86xrsM,1597
108
- aline_ai-0.6.7.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
109
- aline_ai-0.6.7.dist-info/entry_points.txt,sha256=TvYELpMoWsUTcQdMV8tBHxCbEf_LbK4sESqK3r8PM6Y,78
110
- aline_ai-0.6.7.dist-info/top_level.txt,sha256=yIL3s2xv9nf1GwD5n71Aq_JEIV4AfzCIDNKBzewuRm4,8
111
- aline_ai-0.6.7.dist-info/RECORD,,
107
+ aline_ai-0.7.1.dist-info/METADATA,sha256=Kj-SGQc0F5dLAvywJrieZbtE93ZkjCqPnozlsSgt_Ds,1597
108
+ aline_ai-0.7.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
109
+ aline_ai-0.7.1.dist-info/entry_points.txt,sha256=TvYELpMoWsUTcQdMV8tBHxCbEf_LbK4sESqK3r8PM6Y,78
110
+ aline_ai-0.7.1.dist-info/top_level.txt,sha256=yIL3s2xv9nf1GwD5n71Aq_JEIV4AfzCIDNKBzewuRm4,8
111
+ aline_ai-0.7.1.dist-info/RECORD,,
realign/__init__.py CHANGED
@@ -3,7 +3,7 @@
3
3
  import hashlib
4
4
  from pathlib import Path
5
5
 
6
- __version__ = "0.6.7"
6
+ __version__ = "0.7.1"
7
7
 
8
8
 
9
9
  def get_realign_dir(project_root: Path) -> Path:
realign/cli.py CHANGED
@@ -127,7 +127,7 @@ def main(
127
127
 
128
128
  from .dashboard.app import AlineDashboard
129
129
 
130
- dashboard = AlineDashboard(dev_mode=dev, use_native_terminal=use_native_terminal)
130
+ dashboard = AlineDashboard(use_native_terminal=use_native_terminal)
131
131
  dashboard.run()
132
132
 
133
133
 
@@ -311,6 +311,76 @@ def agent_share_cli(
311
311
  raise typer.Exit(code=exit_code)
312
312
 
313
313
 
314
+ @agent_app.command(name="import")
315
+ def agent_import_cli(
316
+ share_url: str = typer.Argument(..., help="Share URL to import agent from"),
317
+ password: Optional[str] = typer.Option(
318
+ None, "--password", "-p", help="Password for encrypted share"
319
+ ),
320
+ ):
321
+ """Import an agent from a share link.
322
+
323
+ Downloads the shared agent data and creates a local agent with all its sessions.
324
+ For unencrypted shares, sync is automatically enabled so you can push/pull updates.
325
+
326
+ Examples:
327
+ aline agent import https://realign-server.vercel.app/share/abc123
328
+ aline agent import https://realign-server.vercel.app/share/xyz789 --password mypass
329
+ """
330
+ from .commands.import_shares import import_agent_from_share
331
+
332
+ result = import_agent_from_share(share_url=share_url, password=password)
333
+
334
+ if result["success"]:
335
+ console.print(f"\n[green]✅ Agent imported successfully![/green]")
336
+ console.print(f"[cyan]Agent ID:[/cyan] {result['agent_id']}")
337
+ console.print(f"[cyan]Agent Name:[/cyan] {result['agent_name']}")
338
+ if result.get('agent_description'):
339
+ console.print(f"[cyan]Description:[/cyan] {result['agent_description'][:100]}...")
340
+ console.print(f"[cyan]Sessions imported:[/cyan] {result['sessions_imported']}")
341
+ console.print(f"[cyan]Turns imported:[/cyan] {result['turns_imported']}")
342
+ if result.get('sync_enabled'):
343
+ console.print(f"\n[green]✓ Sync enabled![/green] Use 'aline agent sync {result['agent_id'][:8]}' to sync updates.")
344
+ raise typer.Exit(code=0)
345
+ else:
346
+ console.print(f"\n[red]❌ Import failed:[/red] {result['error']}")
347
+ raise typer.Exit(code=1)
348
+
349
+
350
+ @agent_app.command(name="sync")
351
+ def agent_sync_cli(
352
+ agent_id: str = typer.Argument(..., help="Agent ID to sync"),
353
+ ):
354
+ """Sync an agent's sessions with the remote share.
355
+
356
+ Performs bidirectional sync: pulls new sessions from remote, merges locally,
357
+ then pushes merged state back. Uses optimistic locking to handle conflicts.
358
+
359
+ Only works with agents that were shared or imported with sync enabled.
360
+
361
+ Examples:
362
+ aline agent sync abc123de
363
+ """
364
+ from .commands.sync_agent import sync_agent_command
365
+
366
+ def progress_callback(msg: str) -> None:
367
+ console.print(f"[dim]{msg}[/dim]")
368
+
369
+ result = sync_agent_command(agent_id=agent_id, progress_callback=progress_callback)
370
+
371
+ if result["success"]:
372
+ console.print(f"\n[green]✅ Sync completed![/green]")
373
+ console.print(f"[cyan]Sessions pulled:[/cyan] {result['sessions_pulled']}")
374
+ console.print(f"[cyan]Sessions pushed:[/cyan] {result['sessions_pushed']}")
375
+ if result.get('description_updated'):
376
+ console.print(f"[cyan]Description updated:[/cyan] Yes")
377
+ console.print(f"[cyan]New sync version:[/cyan] {result['new_sync_version']}")
378
+ raise typer.Exit(code=0)
379
+ else:
380
+ console.print(f"\n[red]❌ Sync failed:[/red] {result['error']}")
381
+ raise typer.Exit(code=1)
382
+
383
+
314
384
  @add_app.command(name="tmux")
315
385
  def add_tmux_cli():
316
386
  """Install tmux via Homebrew and set up Aline tmux clipboard bindings."""
@@ -1022,9 +1092,7 @@ def dashboard(
1022
1092
 
1023
1093
  from .dashboard.app import AlineDashboard
1024
1094
 
1025
- # Use dev flag from this command or inherit from parent context
1026
- dev_mode = dev or (ctx.obj.get("dev", False) if ctx.obj else False)
1027
- dash = AlineDashboard(dev_mode=dev_mode, use_native_terminal=use_native_terminal)
1095
+ dash = AlineDashboard(use_native_terminal=use_native_terminal)
1028
1096
  dash.run()
1029
1097
  except Exception as e:
1030
1098
  logger.error(f"Dashboard crashed: {e}\n{traceback.format_exc()}")
realign/commands/auth.py CHANGED
@@ -170,6 +170,10 @@ def login_command() -> int:
170
170
  print("User ID synced to local config")
171
171
 
172
172
  logger.info(f"Login successful for {credentials.email}")
173
+
174
+ # Start watcher and worker daemons after login
175
+ _start_daemons()
176
+
173
177
  return 0
174
178
 
175
179
 
@@ -227,6 +231,22 @@ def logout_command() -> int:
227
231
  return 0
228
232
 
229
233
 
234
+ def _start_daemons() -> None:
235
+ """Start watcher and worker daemons if not already running."""
236
+ try:
237
+ from . import watcher as watcher_cmd
238
+
239
+ exit_code = watcher_cmd.watcher_start_command()
240
+ if exit_code == 0:
241
+ if console:
242
+ console.print("[dim]Watcher and worker daemons started.[/dim]")
243
+ logger.info("Daemons started after login")
244
+ else:
245
+ logger.debug(f"watcher_start_command returned {exit_code}")
246
+ except Exception as e:
247
+ logger.debug(f"Failed to start daemons after login: {e}")
248
+
249
+
230
250
  def _stop_daemons() -> None:
231
251
  """Stop watcher and worker daemons."""
232
252
  import os
@@ -202,9 +202,9 @@ def import_agent_from_share(
202
202
  total_turns += import_result["turns"]
203
203
  except Exception as e:
204
204
  logger.error(f"Failed to import session {session_id}: {e}")
205
- continue
205
+ # Don't continue - still try to link the session if it exists
206
206
 
207
- # Link session to agent
207
+ # Link session to agent (always attempt, even if import was skipped/failed)
208
208
  try:
209
209
  db.update_session_agent_id(session_id, agent_id)
210
210
  except Exception as e:
@@ -74,9 +74,19 @@ def sync_agent_command(
74
74
  # 1. Load local state
75
75
  _progress("Loading local agent data...")
76
76
 
77
+ # Support prefix matching for agent_id
77
78
  agent_info = db.get_agent_info(agent_id)
78
79
  if not agent_info:
79
- return {"success": False, "error": f"Agent not found: {agent_id}"}
80
+ # Try prefix match
81
+ all_agents = db.list_agent_info()
82
+ matches = [a for a in all_agents if a.id.startswith(agent_id)]
83
+ if len(matches) == 1:
84
+ agent_info = matches[0]
85
+ agent_id = agent_info.id
86
+ elif len(matches) > 1:
87
+ return {"success": False, "error": f"Ambiguous agent_id prefix '{agent_id}' matches {len(matches)} agents"}
88
+ else:
89
+ return {"success": False, "error": f"Agent not found: {agent_id}"}
80
90
 
81
91
  if not agent_info.share_id or not agent_info.share_url:
82
92
  return {"success": False, "error": "Agent has no share metadata (not shared yet)"}
@@ -88,6 +98,29 @@ def sync_agent_command(
88
98
  share_id = agent_info.share_id
89
99
  local_sync_version = agent_info.sync_version or 0
90
100
 
101
+ # Repair: backfill agent_id on sessions linked via windowlink but missing agent_id.
102
+ # This handles Claude sessions where the watcher created the session before the
103
+ # agent_id was known (race between polling and stop-hook signals).
104
+ try:
105
+ conn = db._get_connection()
106
+ unlinked = conn.execute(
107
+ """SELECT DISTINCT w.session_id
108
+ FROM windowlink w
109
+ JOIN sessions s ON s.id = w.session_id
110
+ WHERE w.agent_id = ?
111
+ AND (s.agent_id IS NULL OR s.agent_id = '')""",
112
+ (agent_id,),
113
+ ).fetchall()
114
+ for row in unlinked:
115
+ sid = row[0]
116
+ if sid:
117
+ db.update_session_agent_id(sid, agent_id)
118
+ logger.info(f"Sync repair: linked session {sid} to agent {agent_id}")
119
+ if unlinked:
120
+ _progress(f"Repaired {len(unlinked)} unlinked session(s)")
121
+ except Exception as e:
122
+ logger.warning(f"Session repair step failed (non-fatal): {e}")
123
+
91
124
  local_sessions = db.get_sessions_by_agent_id(agent_id)
92
125
  local_content_hashes = db.get_agent_content_hashes(agent_id)
93
126
 
@@ -129,29 +162,44 @@ def sync_agent_command(
129
162
  session_id = session_data.get("session_id", "")
130
163
  session_turns = session_data.get("turns", [])
131
164
 
132
- # Check if any turns in this session are new to us
165
+ # Check if any turns in this session are new to THIS AGENT (not globally)
133
166
  new_turns = [
134
167
  t for t in session_turns
135
168
  if t.get("content_hash") and t["content_hash"] not in local_content_hashes
136
169
  ]
137
170
 
138
- if not new_turns:
171
+ # Check if session exists and whether it's linked to this agent
172
+ existing_session = db.get_session_by_id(session_id)
173
+ session_is_new = existing_session is None
174
+ session_needs_linking = existing_session and existing_session.agent_id != agent_id
175
+
176
+ # Import if: new turns, or session is new, or session needs linking
177
+ if not new_turns and not session_is_new and not session_needs_linking:
139
178
  continue
140
179
 
141
180
  # Import the session (import_session_with_turns handles dedup by content_hash)
181
+ should_count = session_is_new or session_needs_linking
142
182
  try:
143
183
  # Suppress auto-summaries during sync
144
184
  os.environ["REALIGN_DISABLE_AUTO_SUMMARIES"] = "1"
145
185
  import_result = import_session_with_turns(
146
186
  session_data, f"agent-{agent_id}", agent_info.share_url, db, force=False
147
187
  )
148
- if import_result.get("sessions", 0) > 0 or import_result.get("turns", 0) > 0:
188
+ # Count as pulled if: created new session/turns, or session was new/needed linking
189
+ if (import_result.get("sessions", 0) > 0 or import_result.get("turns", 0) > 0
190
+ or should_count):
191
+ sessions_pulled += 1
192
+ except Exception as e:
193
+ logger.error(f"Failed to import remote session {session_id}: {e}")
194
+ # Still count if we intended to import this session
195
+ if should_count:
149
196
  sessions_pulled += 1
150
197
 
151
- # Link session to agent
198
+ # Always link session to agent (even if import was skipped)
199
+ try:
152
200
  db.update_session_agent_id(session_id, agent_id)
153
201
  except Exception as e:
154
- logger.error(f"Failed to import remote session {session_id}: {e}")
202
+ logger.error(f"Failed to link session {session_id} to agent: {e}")
155
203
 
156
204
  # Merge name/description: last-write-wins by updated_at
157
205
  description_updated = False
realign/dashboard/app.py CHANGED
@@ -13,8 +13,6 @@ from textual.widgets import Footer, TabbedContent, TabPane
13
13
  from ..logging_config import setup_logger
14
14
  from .widgets import (
15
15
  AlineHeader,
16
- WatcherPanel,
17
- WorkerPanel,
18
16
  ConfigPanel,
19
17
  AgentsPanel,
20
18
  )
@@ -64,30 +62,24 @@ class AlineDashboard(App):
64
62
  Binding("?", "help", "Help"),
65
63
  Binding("tab", "next_tab", "Next Tab", priority=True, show=False),
66
64
  Binding("shift+tab", "prev_tab", "Prev Tab", priority=True, show=False),
67
- Binding("n", "page_next", "Next Page", show=False),
68
- Binding("p", "page_prev", "Prev Page", show=False),
69
- Binding("s", "switch_view", "Switch View", show=False),
70
65
  Binding("ctrl+c", "quit_confirm", "Quit", priority=True),
71
66
  ]
72
67
 
73
68
  _quit_confirm_window_s: float = 1.2
74
69
 
75
- def __init__(self, dev_mode: bool = False, use_native_terminal: bool | None = None):
70
+ def __init__(self, use_native_terminal: bool | None = None):
76
71
  """Initialize the dashboard.
77
72
 
78
73
  Args:
79
- dev_mode: If True, shows developer tabs (Watcher, Worker).
80
74
  use_native_terminal: If True, use native terminal backend (iTerm2/Kitty).
81
75
  If False, use tmux.
82
76
  If None (default), auto-detect from ALINE_TERMINAL_MODE env var.
83
77
  """
84
78
  super().__init__()
85
- self.dev_mode = dev_mode
86
79
  self.use_native_terminal = use_native_terminal
87
80
  self._native_terminal_mode = self._detect_native_mode()
88
81
  logger.info(
89
- f"AlineDashboard initialized (dev_mode={dev_mode}, "
90
- f"native_terminal={self._native_terminal_mode})"
82
+ f"AlineDashboard initialized (native_terminal={self._native_terminal_mode})"
91
83
  )
92
84
 
93
85
  def _detect_native_mode(self) -> bool:
@@ -103,15 +95,9 @@ class AlineDashboard(App):
103
95
  logger.debug("compose() started")
104
96
  try:
105
97
  yield AlineHeader()
106
- tab_ids = self._tab_ids()
107
- with TabbedContent(initial=tab_ids[0] if tab_ids else "agents"):
98
+ with TabbedContent(initial="agents"):
108
99
  with TabPane("Agents", id="agents"):
109
100
  yield AgentsPanel()
110
- if self.dev_mode:
111
- with TabPane("Watcher", id="watcher"):
112
- yield WatcherPanel()
113
- with TabPane("Worker", id="worker"):
114
- yield WorkerPanel()
115
101
  with TabPane("Config", id="config"):
116
102
  yield ConfigPanel()
117
103
  yield Footer()
@@ -121,8 +107,6 @@ class AlineDashboard(App):
121
107
  raise
122
108
 
123
109
  def _tab_ids(self) -> list[str]:
124
- if self.dev_mode:
125
- return ["agents", "watcher", "worker", "config"]
126
110
  return ["agents", "config"]
127
111
 
128
112
  def on_mount(self) -> None:
@@ -207,43 +191,9 @@ class AlineDashboard(App):
207
191
 
208
192
  if active_tab_id == "agents":
209
193
  self.query_one(AgentsPanel).refresh_data()
210
- elif active_tab_id == "watcher":
211
- self.query_one(WatcherPanel).refresh_data()
212
- elif active_tab_id == "worker":
213
- self.query_one(WorkerPanel).refresh_data()
214
194
  elif active_tab_id == "config":
215
195
  self.query_one(ConfigPanel).refresh_data()
216
196
 
217
- def action_page_next(self) -> None:
218
- """Go to next page in current panel."""
219
- tabbed_content = self.query_one(TabbedContent)
220
- active_tab_id = tabbed_content.active
221
-
222
- if active_tab_id == "watcher":
223
- self.query_one(WatcherPanel).action_next_page()
224
- elif active_tab_id == "worker":
225
- self.query_one(WorkerPanel).action_next_page()
226
-
227
- def action_page_prev(self) -> None:
228
- """Go to previous page in current panel."""
229
- tabbed_content = self.query_one(TabbedContent)
230
- active_tab_id = tabbed_content.active
231
-
232
- if active_tab_id == "watcher":
233
- self.query_one(WatcherPanel).action_prev_page()
234
- elif active_tab_id == "worker":
235
- self.query_one(WorkerPanel).action_prev_page()
236
-
237
- def action_switch_view(self) -> None:
238
- """Switch view in current panel (if supported)."""
239
- tabbed_content = self.query_one(TabbedContent)
240
- active_tab_id = tabbed_content.active
241
-
242
- if active_tab_id == "watcher":
243
- self.query_one(WatcherPanel).action_switch_view()
244
- elif active_tab_id == "worker":
245
- self.query_one(WorkerPanel).action_switch_view()
246
-
247
197
  def action_help(self) -> None:
248
198
  """Show help information."""
249
199
  from .screens import HelpScreen
@@ -1,8 +1,6 @@
1
1
  """Aline Dashboard Widgets."""
2
2
 
3
3
  from .header import AlineHeader
4
- from .watcher_panel import WatcherPanel
5
- from .worker_panel import WorkerPanel
6
4
  from .sessions_table import SessionsTable
7
5
  from .events_table import EventsTable
8
6
  from .config_panel import ConfigPanel
@@ -12,8 +10,6 @@ from .agents_panel import AgentsPanel
12
10
 
13
11
  __all__ = [
14
12
  "AlineHeader",
15
- "WatcherPanel",
16
- "WorkerPanel",
17
13
  "SessionsTable",
18
14
  "EventsTable",
19
15
  "ConfigPanel",
@@ -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,6 +164,7 @@ 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
168
170
  self._sync_worker: Optional[Worker] = None
@@ -178,7 +180,7 @@ class AgentsPanel(Container, can_focus=True):
178
180
 
179
181
  def on_show(self) -> None:
180
182
  if self._refresh_timer is None:
181
- 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)
182
184
  else:
183
185
  try:
184
186
  self._refresh_timer.resume()
@@ -305,6 +307,14 @@ class AgentsPanel(Container, can_focus=True):
305
307
  return agents
306
308
 
307
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
+
308
318
  def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
309
319
  # Handle refresh worker
310
320
  if self._refresh_worker is not None and event.worker is self._refresh_worker:
@@ -314,6 +324,10 @@ class AgentsPanel(Container, can_focus=True):
314
324
  self._agents = self._refresh_worker.result or []
315
325
  else:
316
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
317
331
  self.run_worker(
318
332
  self._render_agents(), group="agents-render", exclusive=True
319
333
  )
realign/watcher_core.py CHANGED
@@ -245,12 +245,129 @@ class DialogueWatcher:
245
245
  # a new run may take over and re-process it to avoid permanent stuck states.
246
246
  self.processing_turn_ttl_seconds = 20 * 60 # 20 minutes
247
247
 
248
+ # Layer 1: Session list cache (avoid re-scanning directories every 0.5s)
249
+ self._cached_session_list: list[Path] = []
250
+ self._session_list_last_scan: float = 0.0
251
+ self._session_list_scan_interval: float = 30.0
252
+
253
+ # Layer 2: Per-cycle sizes stash (shared between check_for_changes and idle check)
254
+ self._last_cycle_sizes: Dict[str, int] = {}
255
+
256
+ # Layer 3: Turn count cache (avoid re-parsing JSONL for unchanged files)
257
+ self._cached_turn_counts: dict[str, int] = {}
258
+ self._cached_total_turn_counts: dict[str, int] = {}
259
+ self._turn_count_file_stats: dict[str, tuple[float, int]] = {}
260
+
248
261
  # Signal directory for Stop hook integration
249
262
  self.signal_dir = Path.home() / ".aline" / ".signals"
250
263
  self.signal_dir.mkdir(parents=True, exist_ok=True)
251
264
  self.user_prompt_signal_dir = self.signal_dir / "user_prompt_submit"
252
265
  self.user_prompt_signal_dir.mkdir(parents=True, exist_ok=True)
253
266
 
267
+ def _get_cached_session_list(self, force_rescan: bool = False) -> list[Path]:
268
+ """Return cached list of active session files, re-scanning only every 30s.
269
+
270
+ Args:
271
+ force_rescan: If True, bypass the time gate and re-scan immediately.
272
+ """
273
+ now = time.time()
274
+ if (
275
+ not force_rescan
276
+ and self._cached_session_list
277
+ and (now - self._session_list_last_scan) < self._session_list_scan_interval
278
+ ):
279
+ return self._cached_session_list
280
+
281
+ try:
282
+ self._cached_session_list = find_all_active_sessions(self.config, project_path=None)
283
+ self._session_list_last_scan = now
284
+ except PermissionError:
285
+ if not hasattr(self, "_permission_error_logged"):
286
+ self._permission_error_logged = True
287
+ logger.error("PERMISSION DENIED: Cannot access Claude Code sessions directory")
288
+ except Exception as e:
289
+ logger.error(f"Error scanning session list: {e}", exc_info=True)
290
+
291
+ return self._cached_session_list
292
+
293
+ def _get_cycle_session_stats(self) -> tuple[list[Path], Dict[str, int], Dict[str, float]]:
294
+ """Single stat() pass per cycle over the cached session list.
295
+
296
+ Returns:
297
+ (session_files, sizes_dict, mtimes_dict)
298
+ """
299
+ session_files = self._get_cached_session_list()
300
+ sizes: Dict[str, int] = {}
301
+ mtimes: Dict[str, float] = {}
302
+ force_rescan = False
303
+
304
+ for session_file in list(session_files): # copy so we can mutate
305
+ path_key = str(session_file)
306
+ try:
307
+ if session_file.is_dir():
308
+ # Handle directory-based sessions (e.g., Antigravity brain directories)
309
+ artifacts = ["task.md", "walkthrough.md", "implementation_plan.md"]
310
+ total_size = 0
311
+ max_mtime = 0.0
312
+ for artifact_name in artifacts:
313
+ artifact_path = session_file / artifact_name
314
+ if artifact_path.exists():
315
+ artifact_stat = artifact_path.stat()
316
+ total_size += artifact_stat.st_size
317
+ max_mtime = max(max_mtime, artifact_stat.st_mtime)
318
+ if max_mtime > 0:
319
+ sizes[path_key] = total_size
320
+ mtimes[path_key] = max_mtime
321
+ else:
322
+ stat = session_file.stat()
323
+ sizes[path_key] = stat.st_size
324
+ mtimes[path_key] = stat.st_mtime
325
+ except FileNotFoundError:
326
+ # File disappeared — prune from cache, force re-scan next cycle
327
+ if session_file in self._cached_session_list:
328
+ self._cached_session_list.remove(session_file)
329
+ force_rescan = True
330
+ except Exception as e:
331
+ logger.debug(f"Error stat-ing {path_key}: {e}")
332
+
333
+ if force_rescan:
334
+ self._session_list_last_scan = 0.0 # Force re-scan next cycle
335
+
336
+ live_files = [f for f in session_files if str(f) in sizes]
337
+ return live_files, sizes, mtimes
338
+
339
+ def _get_cached_turn_counts(
340
+ self, session_file: Path, current_mtime: float, current_size: int
341
+ ) -> tuple[int, int]:
342
+ """Return (completed_turns, total_turns) for a session, using cache when file unchanged.
343
+
344
+ Args:
345
+ session_file: Path to the session file.
346
+ current_mtime: The mtime from the current cycle's stat pass.
347
+ current_size: The size from the current cycle's stat pass.
348
+
349
+ Returns:
350
+ (completed_turn_count, total_turn_count)
351
+ """
352
+ path_key = str(session_file)
353
+ cached_stats = self._turn_count_file_stats.get(path_key)
354
+
355
+ if cached_stats and cached_stats == (current_mtime, current_size):
356
+ return (
357
+ self._cached_turn_counts.get(path_key, 0),
358
+ self._cached_total_turn_counts.get(path_key, 0),
359
+ )
360
+
361
+ # File changed — re-parse
362
+ completed = self._count_complete_turns(session_file)
363
+ total = self._get_total_turn_count(session_file)
364
+
365
+ self._cached_turn_counts[path_key] = completed
366
+ self._cached_total_turn_counts[path_key] = total
367
+ self._turn_count_file_stats[path_key] = (current_mtime, current_size)
368
+
369
+ return completed, total
370
+
254
371
  def _maybe_link_codex_terminal(self, session_file: Path) -> None:
255
372
  """Best-effort: bind a Codex session file to the most likely active Codex terminal."""
256
373
  try:
@@ -468,11 +585,15 @@ class DialogueWatcher:
468
585
  if self.config.enable_temp_turn_titles:
469
586
  await self._check_user_prompt_submit_signals()
470
587
 
588
+ # Single stat pass per cycle (Layer 2)
589
+ cycle_sessions, cycle_sizes, cycle_mtimes = self._get_cycle_session_stats()
590
+ self._last_cycle_sizes = cycle_sizes
591
+
471
592
  # Priority 2: Fallback polling mechanism (with debounce)
472
- await self.check_for_changes()
593
+ await self.check_for_changes(cycle_sizes, cycle_mtimes)
473
594
 
474
595
  # Check for idle sessions that need final commit
475
- await self._check_idle_sessions_for_final_commit()
596
+ await self._check_idle_sessions_for_final_commit(cycle_sessions, cycle_mtimes)
476
597
 
477
598
  await asyncio.sleep(0.5) # Check every 0.5 seconds for responsiveness
478
599
  except Exception as e:
@@ -555,7 +676,15 @@ class DialogueWatcher:
555
676
  logger.warning(
556
677
  f"Failed to enqueue stop-hook job for {session_id}: {e}"
557
678
  )
558
- continue
679
+
680
+ # Directly link session to agent (in case the turn job
681
+ # was already processed by polling without agent_id
682
+ # and the new enqueue was deduped).
683
+ if agent_id and session_id:
684
+ try:
685
+ db.update_session_agent_id(session_id, agent_id)
686
+ except Exception:
687
+ pass
559
688
 
560
689
  logger.info(
561
690
  f"Enqueued turn_summary via Stop hook: {session_id} turn {target_turn} ({project_path.name})"
@@ -737,66 +866,11 @@ class DialogueWatcher:
737
866
  def _get_session_stats(self) -> tuple[Dict[str, int], Dict[str, float]]:
738
867
  """Get (sizes, mtimes) for all active session files.
739
868
 
740
- For directory-based sessions (e.g., Antigravity), computes:
741
- - size: sum of all artifact file sizes
742
- - mtime: max mtime of all artifact files
743
-
744
- This ensures changes to individual artifacts are detected.
869
+ Delegates to _get_cycle_session_stats() which uses the cached session list
870
+ and performs a single stat() pass.
745
871
  """
746
- sizes: Dict[str, int] = {}
747
- mtimes: Dict[str, float] = {}
748
- try:
749
- session_files = find_all_active_sessions(self.config, project_path=None)
750
- for session_file in session_files:
751
- if not session_file.exists():
752
- continue
753
- path_key = str(session_file)
754
-
755
- # Handle directory-based sessions (e.g., Antigravity brain directories)
756
- if session_file.is_dir():
757
- # Check for Antigravity-style artifacts
758
- artifacts = ["task.md", "walkthrough.md", "implementation_plan.md"]
759
- total_size = 0
760
- max_mtime = 0.0
761
-
762
- for artifact_name in artifacts:
763
- artifact_path = session_file / artifact_name
764
- if artifact_path.exists():
765
- artifact_stat = artifact_path.stat()
766
- total_size += artifact_stat.st_size
767
- max_mtime = max(max_mtime, artifact_stat.st_mtime)
768
-
769
- # Only track if at least one artifact exists
770
- if max_mtime > 0:
771
- sizes[path_key] = total_size
772
- mtimes[path_key] = max_mtime
773
- else:
774
- # Regular file-based session
775
- stat = session_file.stat()
776
- sizes[path_key] = stat.st_size
777
- mtimes[path_key] = stat.st_mtime
778
-
779
- logger.debug(f"Tracked {len(sizes)} session file(s) across all projects")
780
- except PermissionError as e:
781
- # macOS permission issue - only log once
782
- if not hasattr(self, "_permission_error_logged"):
783
- self._permission_error_logged = True
784
- logger.error("PERMISSION DENIED: Cannot access Claude Code sessions directory")
785
- print(
786
- "[Watcher] ✗ PERMISSION DENIED: Cannot access ~/.claude/projects/",
787
- file=sys.stderr,
788
- )
789
- print(
790
- "[Watcher] ⓘ Grant Full Disk Access to Acme in System Preferences",
791
- file=sys.stderr,
792
- )
793
- print(
794
- "[Watcher] ⓘ System Preferences → Privacy & Security → Full Disk Access → Add Acme",
795
- file=sys.stderr,
796
- )
797
- except Exception as e:
798
- logger.error(f"Error getting session stats: {e}", exc_info=True)
799
- print(f"[Watcher] Error getting session stats: {e}", file=sys.stderr)
872
+ _, sizes, mtimes = self._get_cycle_session_stats()
873
+ logger.debug(f"Tracked {len(sizes)} session file(s) across all projects")
800
874
  return sizes, mtimes
801
875
 
802
876
  def _get_session_sizes(self) -> Dict[str, int]:
@@ -965,23 +1039,22 @@ class DialogueWatcher:
965
1039
  logger.warning(f"Failed to compute hash for {session_file.name}: {e}")
966
1040
  return None
967
1041
 
968
- async def _check_idle_sessions_for_final_commit(self):
1042
+ async def _check_idle_sessions_for_final_commit(self, session_files: Optional[list[Path]] = None, current_mtimes: Optional[Dict[str, float]] = None):
969
1043
  """Check for idle sessions and trigger final commits if needed."""
970
1044
  try:
971
1045
  current_time = time.time()
972
- # Always use multi-project mode (project_path=None)
973
- session_files = find_all_active_sessions(self.config, project_path=None)
1046
+ if session_files is None:
1047
+ session_files = self._get_cached_session_list()
1048
+ if current_mtimes is None:
1049
+ _, _, current_mtimes = self._get_cycle_session_stats()
974
1050
 
975
1051
  for session_file in session_files:
976
- if not session_file.exists():
977
- continue
978
-
979
1052
  session_path = str(session_file)
1053
+ mtime = current_mtimes.get(session_path)
1054
+ if mtime is None:
1055
+ continue
980
1056
 
981
1057
  try:
982
- # Get current mtime
983
- mtime = session_file.stat().st_mtime
984
-
985
1058
  # Initialize tracking if first time seeing this session
986
1059
  if session_path not in self.last_session_mtimes:
987
1060
  self.last_session_mtimes[session_path] = mtime
@@ -1010,13 +1083,16 @@ class DialogueWatcher:
1010
1083
 
1011
1084
  session_id = session_file.stem
1012
1085
  session_type = self._detect_session_type(session_file)
1013
- completed_count = self._count_complete_turns(session_file)
1086
+ # Use cached turn counts (Layer 3) — avoids re-parsing unchanged files
1087
+ file_size = self._last_cycle_sizes.get(session_path, 0)
1088
+ completed_count, total_turns = self._get_cached_turn_counts(
1089
+ session_file, mtime, file_size
1090
+ )
1014
1091
  last_count = self.last_stop_reason_counts.get(session_path, 0)
1015
1092
 
1016
1093
  # For Claude, also consider the last turn (not counted in completed_count).
1017
1094
  last_turn_to_enqueue: Optional[int] = None
1018
1095
  if session_type == "claude":
1019
- total_turns = self._get_total_turn_count(session_file)
1020
1096
  if total_turns > completed_count:
1021
1097
  existing = db.get_turn_by_number(session_id, int(total_turns))
1022
1098
  if existing is None:
@@ -1221,10 +1297,11 @@ class DialogueWatcher:
1221
1297
  logger.error(f"Trigger error for {session_file.name}: {e}")
1222
1298
  return 0
1223
1299
 
1224
- async def check_for_changes(self):
1300
+ async def check_for_changes(self, current_sizes: Optional[Dict[str, int]] = None, current_mtimes: Optional[Dict[str, float]] = None):
1225
1301
  """Check if any session file has been modified."""
1226
1302
  try:
1227
- current_sizes, current_mtimes = self._get_session_stats()
1303
+ if current_sizes is None or current_mtimes is None:
1304
+ current_sizes, current_mtimes = self._get_session_stats()
1228
1305
 
1229
1306
  # Detect changed files
1230
1307
  changed_files = []
realign/worker_core.py CHANGED
@@ -279,6 +279,8 @@ class AlineWorker:
279
279
  return
280
280
 
281
281
  agent_info_id = None
282
+
283
+ # Method 1: Match via agents table session_id (works for Codex)
282
284
  for agent in agents:
283
285
  try:
284
286
  if (agent.session_id or "").strip() != session_id:
@@ -290,6 +292,21 @@ class AlineWorker:
290
292
  except Exception:
291
293
  continue
292
294
 
295
+ # Method 2: Match via windowlink table (works for Claude)
296
+ if not agent_info_id:
297
+ try:
298
+ conn = self.db._get_connection()
299
+ row = conn.execute(
300
+ """SELECT w.agent_id FROM windowlink w
301
+ WHERE w.session_id = ? AND w.agent_id IS NOT NULL
302
+ ORDER BY w.ts DESC LIMIT 1""",
303
+ (session_id,),
304
+ ).fetchone()
305
+ if row and row[0]:
306
+ agent_info_id = row[0]
307
+ except Exception:
308
+ pass
309
+
293
310
  if agent_info_id:
294
311
  try:
295
312
  self.db.update_session_agent_id(session_id, agent_info_id)