aline-ai 0.5.3__py3-none-any.whl → 0.5.5__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.5.3.dist-info → aline_ai-0.5.5.dist-info}/METADATA +1 -1
- aline_ai-0.5.5.dist-info/RECORD +93 -0
- realign/__init__.py +1 -1
- realign/adapters/antigravity.py +28 -20
- realign/adapters/base.py +46 -50
- realign/adapters/claude.py +14 -14
- realign/adapters/codex.py +7 -7
- realign/adapters/gemini.py +11 -11
- realign/adapters/registry.py +14 -10
- realign/claude_detector.py +2 -2
- realign/claude_hooks/__init__.py +3 -3
- realign/claude_hooks/permission_request_hook.py +35 -0
- realign/claude_hooks/permission_request_hook_installer.py +31 -32
- realign/claude_hooks/stop_hook.py +4 -1
- realign/claude_hooks/stop_hook_installer.py +30 -31
- realign/cli.py +24 -0
- realign/codex_detector.py +11 -11
- realign/commands/add.py +361 -35
- realign/commands/config.py +3 -12
- realign/commands/context.py +3 -1
- realign/commands/export_shares.py +86 -127
- realign/commands/import_shares.py +145 -155
- realign/commands/init.py +166 -30
- realign/commands/restore.py +18 -6
- realign/commands/search.py +14 -42
- realign/commands/upgrade.py +155 -11
- realign/commands/watcher.py +98 -219
- realign/commands/worker.py +29 -6
- realign/config.py +25 -20
- realign/context.py +1 -3
- realign/dashboard/app.py +4 -4
- realign/dashboard/screens/create_event.py +3 -1
- realign/dashboard/screens/event_detail.py +14 -6
- realign/dashboard/screens/session_detail.py +3 -1
- realign/dashboard/screens/share_import.py +7 -3
- realign/dashboard/tmux_manager.py +91 -22
- realign/dashboard/widgets/config_panel.py +85 -1
- realign/dashboard/widgets/events_table.py +3 -1
- realign/dashboard/widgets/header.py +1 -0
- realign/dashboard/widgets/search_panel.py +37 -27
- realign/dashboard/widgets/sessions_table.py +24 -15
- realign/dashboard/widgets/terminal_panel.py +207 -17
- realign/dashboard/widgets/watcher_panel.py +6 -2
- realign/dashboard/widgets/worker_panel.py +10 -1
- realign/db/__init__.py +1 -1
- realign/db/base.py +5 -15
- realign/db/locks.py +0 -1
- realign/db/migration.py +82 -76
- realign/db/schema.py +2 -6
- realign/db/sqlite_db.py +23 -41
- realign/events/__init__.py +0 -1
- realign/events/event_summarizer.py +27 -15
- realign/events/session_summarizer.py +29 -15
- realign/file_lock.py +1 -0
- realign/hooks.py +150 -60
- realign/logging_config.py +12 -15
- realign/mcp_server.py +30 -51
- realign/mcp_watcher.py +0 -1
- realign/models/event.py +29 -20
- realign/prompts/__init__.py +7 -7
- realign/prompts/presets.py +15 -11
- realign/redactor.py +99 -59
- realign/triggers/__init__.py +9 -9
- realign/triggers/antigravity_trigger.py +30 -28
- realign/triggers/base.py +4 -3
- realign/triggers/claude_trigger.py +104 -85
- realign/triggers/codex_trigger.py +15 -5
- realign/triggers/gemini_trigger.py +57 -47
- realign/triggers/next_turn_trigger.py +3 -1
- realign/triggers/registry.py +6 -2
- realign/triggers/turn_status.py +3 -1
- realign/watcher_core.py +306 -131
- realign/watcher_daemon.py +8 -8
- realign/worker_core.py +3 -1
- realign/worker_daemon.py +3 -1
- aline_ai-0.5.3.dist-info/RECORD +0 -93
- {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/WHEEL +0 -0
- {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/top_level.txt +0 -0
realign/commands/worker.py
CHANGED
|
@@ -87,7 +87,11 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
|
|
|
87
87
|
# Show job counts if DB available
|
|
88
88
|
try:
|
|
89
89
|
dbi = get_database()
|
|
90
|
-
db_path =
|
|
90
|
+
db_path = (
|
|
91
|
+
Path(str(getattr(dbi, "db_path", ""))).expanduser()
|
|
92
|
+
if isinstance(dbi, SQLiteDatabase)
|
|
93
|
+
else None
|
|
94
|
+
)
|
|
91
95
|
if isinstance(dbi, SQLiteDatabase):
|
|
92
96
|
counts = dbi.get_job_counts()
|
|
93
97
|
turn_jobs = dbi.list_jobs(limit=30, kinds=["turn_summary"])
|
|
@@ -116,6 +120,7 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
|
|
|
116
120
|
job_kinds = {}
|
|
117
121
|
|
|
118
122
|
if json_output:
|
|
123
|
+
|
|
119
124
|
def _session_id_from_job(job: dict) -> str:
|
|
120
125
|
payload = job.get("payload") or {}
|
|
121
126
|
session_id = str(payload.get("session_id") or "").strip()
|
|
@@ -133,7 +138,11 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
|
|
|
133
138
|
session_type_by_id: dict[str, str] = {}
|
|
134
139
|
if isinstance(dbi, SQLiteDatabase):
|
|
135
140
|
session_ids = sorted(
|
|
136
|
-
{
|
|
141
|
+
{
|
|
142
|
+
sid
|
|
143
|
+
for sid in (_session_id_from_job(j) for j in (turn_jobs + session_jobs))
|
|
144
|
+
if sid
|
|
145
|
+
}
|
|
137
146
|
)
|
|
138
147
|
if session_ids:
|
|
139
148
|
for s in dbi.get_sessions_by_ids(session_ids):
|
|
@@ -196,7 +205,12 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
|
|
|
196
205
|
}
|
|
197
206
|
|
|
198
207
|
data = {
|
|
199
|
-
"worker": {
|
|
208
|
+
"worker": {
|
|
209
|
+
"status": status.lower(),
|
|
210
|
+
"running": is_running,
|
|
211
|
+
"pid": pid,
|
|
212
|
+
"mode": mode,
|
|
213
|
+
},
|
|
200
214
|
"db_path": str(db_path) if db_path else None,
|
|
201
215
|
"job_counts": counts,
|
|
202
216
|
"job_kinds": job_kinds,
|
|
@@ -206,7 +220,9 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
|
|
|
206
220
|
print(json.dumps(data, ensure_ascii=True))
|
|
207
221
|
return 0
|
|
208
222
|
|
|
209
|
-
console.print(
|
|
223
|
+
console.print(
|
|
224
|
+
f"[bold]Worker:[/bold] {status}" + (f" (PID: {pid}, mode: {mode})" if pid else "")
|
|
225
|
+
)
|
|
210
226
|
if db_path:
|
|
211
227
|
console.print(f"[dim]DB:[/dim] {db_path}")
|
|
212
228
|
if counts:
|
|
@@ -225,6 +241,7 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
|
|
|
225
241
|
console.print(f"[dim]Job kinds:[/dim] {pairs}")
|
|
226
242
|
|
|
227
243
|
try:
|
|
244
|
+
|
|
228
245
|
def fmt_status(s: str) -> tuple[str, str]:
|
|
229
246
|
s = (s or "").lower()
|
|
230
247
|
if s == "processing":
|
|
@@ -254,7 +271,11 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
|
|
|
254
271
|
session_type_by_id: dict[str, str] = {}
|
|
255
272
|
if isinstance(dbi, SQLiteDatabase):
|
|
256
273
|
session_ids = sorted(
|
|
257
|
-
{
|
|
274
|
+
{
|
|
275
|
+
sid
|
|
276
|
+
for sid in (_session_id_from_job(j) for j in (turn_jobs + session_jobs))
|
|
277
|
+
if sid
|
|
278
|
+
}
|
|
258
279
|
)
|
|
259
280
|
if session_ids:
|
|
260
281
|
for s in dbi.get_sessions_by_ids(session_ids):
|
|
@@ -501,7 +522,9 @@ def worker_stop_command() -> int:
|
|
|
501
522
|
pid, mode = all_processes[0]
|
|
502
523
|
console.print(f"[cyan]Stopping worker (PID: {pid}, mode: {mode})...[/cyan]")
|
|
503
524
|
else:
|
|
504
|
-
console.print(
|
|
525
|
+
console.print(
|
|
526
|
+
f"[cyan]Found {len(all_processes)} worker processes, stopping all...[/cyan]"
|
|
527
|
+
)
|
|
505
528
|
|
|
506
529
|
for pid, _mode in all_processes:
|
|
507
530
|
try:
|
realign/config.py
CHANGED
|
@@ -12,36 +12,38 @@ class ReAlignConfig:
|
|
|
12
12
|
"""ReAlign configuration."""
|
|
13
13
|
|
|
14
14
|
summary_max_chars: int = 500
|
|
15
|
-
redact_on_match: bool = False
|
|
15
|
+
redact_on_match: bool = False # Default: disable redaction (can be enabled in config)
|
|
16
16
|
hooks_installation: str = "repo"
|
|
17
17
|
sqlite_db_path: str = "~/.aline/db/aline.db" # Path to SQLite database
|
|
18
18
|
use_LLM: bool = True
|
|
19
|
-
llm_provider: str = "auto"
|
|
19
|
+
llm_provider: str = "auto" # LLM provider: "auto", "claude", or "openai"
|
|
20
20
|
auto_detect_claude: bool = True # Enable Claude Code session auto-detection
|
|
21
|
-
auto_detect_codex: bool = True
|
|
22
|
-
auto_detect_gemini: bool = True
|
|
23
|
-
auto_detect_antigravity: bool = False
|
|
24
|
-
mcp_auto_commit: bool = True
|
|
21
|
+
auto_detect_codex: bool = True # Enable Codex session auto-detection
|
|
22
|
+
auto_detect_gemini: bool = True # Enable Gemini CLI session auto-detection
|
|
23
|
+
auto_detect_antigravity: bool = False # Enable Antigravity IDE brain artifact monitoring
|
|
24
|
+
mcp_auto_commit: bool = True # Enable watcher auto-commit after each user request completes
|
|
25
25
|
enable_temp_turn_titles: bool = True # Generate temporary turn titles on user prompt submit
|
|
26
|
-
share_backend_url: str =
|
|
26
|
+
share_backend_url: str = (
|
|
27
|
+
"https://realign-server.vercel.app" # Backend URL for interactive share export
|
|
28
|
+
)
|
|
27
29
|
|
|
28
30
|
# User identity (V9)
|
|
29
|
-
user_name: str = ""
|
|
30
|
-
user_id: str = ""
|
|
31
|
+
user_name: str = "" # User's display name (set during init)
|
|
32
|
+
user_id: str = "" # User's UUID (generated from MAC address)
|
|
31
33
|
|
|
32
34
|
# Session catch-up settings
|
|
33
|
-
max_catchup_sessions: int = 3
|
|
35
|
+
max_catchup_sessions: int = 3 # Max sessions to auto-import on watcher startup
|
|
34
36
|
|
|
35
37
|
# LLM API Keys
|
|
36
|
-
anthropic_api_key: Optional[str] = None
|
|
37
|
-
openai_api_key: Optional[str] = None
|
|
38
|
+
anthropic_api_key: Optional[str] = None # Anthropic API key (set in config, not environment)
|
|
39
|
+
openai_api_key: Optional[str] = None # OpenAI API key (set in config, not environment)
|
|
38
40
|
|
|
39
41
|
# LLM Model Configuration
|
|
40
42
|
llm_anthropic_model: str = "claude-3-5-haiku-20241022" # Claude model to use
|
|
41
|
-
llm_openai_model: str = "gpt-4o-mini"
|
|
42
|
-
llm_openai_use_responses: bool = False
|
|
43
|
-
llm_max_tokens: int = 1000
|
|
44
|
-
llm_temperature: float = 0.0
|
|
43
|
+
llm_openai_model: str = "gpt-4o-mini" # OpenAI model to use
|
|
44
|
+
llm_openai_use_responses: bool = False # Use OpenAI Responses API for reasoning models
|
|
45
|
+
llm_max_tokens: int = 1000 # Default max tokens
|
|
46
|
+
llm_temperature: float = 0.0 # Default temperature (0.0 = deterministic)
|
|
45
47
|
|
|
46
48
|
@classmethod
|
|
47
49
|
def load(cls, config_path: Optional[Path] = None) -> "ReAlignConfig":
|
|
@@ -49,12 +51,13 @@ class ReAlignConfig:
|
|
|
49
51
|
if config_path is None:
|
|
50
52
|
# Default to new location: ~/.aline/config.yaml
|
|
51
53
|
config_path = Path.home() / ".aline" / "config.yaml"
|
|
52
|
-
|
|
54
|
+
|
|
53
55
|
# Check for legacy config and migrate if needed
|
|
54
56
|
legacy_path = Path.home() / ".config" / "realign" / "config.yaml"
|
|
55
57
|
if not config_path.exists() and legacy_path.exists():
|
|
56
58
|
try:
|
|
57
59
|
import shutil
|
|
60
|
+
|
|
58
61
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
62
|
shutil.move(legacy_path, config_path)
|
|
60
63
|
# Try to remove empty legacy directory
|
|
@@ -172,10 +175,11 @@ def generate_user_id() -> str:
|
|
|
172
175
|
str: User UUID as a string
|
|
173
176
|
"""
|
|
174
177
|
import uuid
|
|
178
|
+
|
|
175
179
|
try:
|
|
176
180
|
mac = uuid.getnode()
|
|
177
181
|
# Use MAC address with DNS namespace to generate UUID5
|
|
178
|
-
namespace = uuid.UUID(
|
|
182
|
+
namespace = uuid.UUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8") # DNS namespace
|
|
179
183
|
user_uuid = uuid.uuid5(namespace, str(mac))
|
|
180
184
|
return str(user_uuid)
|
|
181
185
|
except Exception:
|
|
@@ -194,8 +198,9 @@ def generate_random_username() -> str:
|
|
|
194
198
|
"""
|
|
195
199
|
import random
|
|
196
200
|
import string
|
|
197
|
-
|
|
198
|
-
|
|
201
|
+
|
|
202
|
+
letters = "".join(random.choices(string.ascii_lowercase, k=3))
|
|
203
|
+
digits = "".join(random.choices(string.digits, k=3))
|
|
199
204
|
return letters + digits
|
|
200
205
|
|
|
201
206
|
|
realign/context.py
CHANGED
|
@@ -245,9 +245,7 @@ def add_context(
|
|
|
245
245
|
entry = config.contexts[existing_idx]
|
|
246
246
|
# Add new sessions/events (merge, deduplicate)
|
|
247
247
|
if sessions:
|
|
248
|
-
entry.context_sessions = list(
|
|
249
|
-
set(entry.context_sessions) | set(sessions)
|
|
250
|
-
)
|
|
248
|
+
entry.context_sessions = list(set(entry.context_sessions) | set(sessions))
|
|
251
249
|
if events:
|
|
252
250
|
entry.context_events = list(set(entry.context_events) | set(events))
|
|
253
251
|
entry.loaded_at = datetime.utcnow().isoformat() + "Z"
|
realign/dashboard/app.py
CHANGED
|
@@ -73,7 +73,9 @@ class AlineDashboard(App):
|
|
|
73
73
|
"""Compose the dashboard layout."""
|
|
74
74
|
yield AlineHeader()
|
|
75
75
|
tab_ids = self._tab_ids()
|
|
76
|
-
with TabbedContent(initial=tab_ids[0] if tab_ids else "
|
|
76
|
+
with TabbedContent(initial=tab_ids[0] if tab_ids else "terminal"):
|
|
77
|
+
with TabPane("Terminal", id="terminal"):
|
|
78
|
+
yield TerminalPanel()
|
|
77
79
|
with TabPane("Watcher", id="watcher"):
|
|
78
80
|
yield WatcherPanel()
|
|
79
81
|
with TabPane("Worker", id="worker"):
|
|
@@ -86,12 +88,10 @@ class AlineDashboard(App):
|
|
|
86
88
|
yield ConfigPanel()
|
|
87
89
|
with TabPane("Search", id="search"):
|
|
88
90
|
yield SearchPanel()
|
|
89
|
-
with TabPane("Terminal", id="terminal"):
|
|
90
|
-
yield TerminalPanel()
|
|
91
91
|
yield Footer()
|
|
92
92
|
|
|
93
93
|
def _tab_ids(self) -> list[str]:
|
|
94
|
-
return ["watcher", "worker", "sessions", "events", "config", "search"
|
|
94
|
+
return ["terminal", "watcher", "worker", "sessions", "events", "config", "search"]
|
|
95
95
|
|
|
96
96
|
def on_mount(self) -> None:
|
|
97
97
|
"""Apply theme based on system settings and watch for changes."""
|
|
@@ -121,7 +121,9 @@ class CreateEventScreen(ModalScreen):
|
|
|
121
121
|
|
|
122
122
|
self.query_one("#create", Button).disabled = True
|
|
123
123
|
self.query_one("#cancel", Button).disabled = True
|
|
124
|
-
self.app.notify(
|
|
124
|
+
self.app.notify(
|
|
125
|
+
"Generating event (aline watcher event generate)...", title="Create Event", timeout=2
|
|
126
|
+
)
|
|
125
127
|
|
|
126
128
|
self._worker = self.run_worker(work, thread=True, exit_on_error=False)
|
|
127
129
|
|
|
@@ -284,7 +284,9 @@ class EventDetailScreen(ModalScreen):
|
|
|
284
284
|
else:
|
|
285
285
|
extra = result.get("stderr") or ""
|
|
286
286
|
suffix = f": {extra}" if extra else ""
|
|
287
|
-
self.app.notify(
|
|
287
|
+
self.app.notify(
|
|
288
|
+
f"Share export failed (exit {exit_code}){suffix}", title="Share", timeout=6
|
|
289
|
+
)
|
|
288
290
|
|
|
289
291
|
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
290
292
|
if event.data_table.id != "event-sessions-table":
|
|
@@ -365,7 +367,7 @@ class EventDetailScreen(ModalScreen):
|
|
|
365
367
|
"antigravity": "Antigravity",
|
|
366
368
|
}
|
|
367
369
|
source = source_map.get(session_type, session_type)
|
|
368
|
-
project =
|
|
370
|
+
project = workspace.split("/")[-1] if workspace else "-"
|
|
369
371
|
|
|
370
372
|
self._sessions.append(
|
|
371
373
|
{
|
|
@@ -374,7 +376,9 @@ class EventDetailScreen(ModalScreen):
|
|
|
374
376
|
"source": source,
|
|
375
377
|
"project": project,
|
|
376
378
|
"turns": int(turn_count or 0),
|
|
377
|
-
"total_turns":
|
|
379
|
+
"total_turns": (
|
|
380
|
+
int(total_turns or 0) if total_turns is not None else None
|
|
381
|
+
),
|
|
378
382
|
"title": title,
|
|
379
383
|
"session_summary": session_summary,
|
|
380
384
|
"last_activity": last_activity,
|
|
@@ -397,7 +401,7 @@ class EventDetailScreen(ModalScreen):
|
|
|
397
401
|
"antigravity": "Antigravity",
|
|
398
402
|
}
|
|
399
403
|
source = source_map.get(session_type, session_type)
|
|
400
|
-
project =
|
|
404
|
+
project = str(workspace).split("/")[-1] if workspace else "-"
|
|
401
405
|
|
|
402
406
|
self._sessions.append(
|
|
403
407
|
{
|
|
@@ -467,7 +471,9 @@ class EventDetailScreen(ModalScreen):
|
|
|
467
471
|
selected_session_id: Optional[str] = None
|
|
468
472
|
try:
|
|
469
473
|
if table.row_count > 0:
|
|
470
|
-
selected_session_id = str(
|
|
474
|
+
selected_session_id = str(
|
|
475
|
+
table.coordinate_to_cell_key(table.cursor_coordinate)[0].value
|
|
476
|
+
)
|
|
471
477
|
except Exception:
|
|
472
478
|
selected_session_id = None
|
|
473
479
|
table.clear()
|
|
@@ -529,7 +535,9 @@ class EventDetailScreen(ModalScreen):
|
|
|
529
535
|
"gemini": "Gemini",
|
|
530
536
|
"antigravity": "Antigravity",
|
|
531
537
|
}
|
|
532
|
-
source = source_map.get(
|
|
538
|
+
source = source_map.get(
|
|
539
|
+
record_type or "", record_type or session.get("source") or "unknown"
|
|
540
|
+
)
|
|
533
541
|
|
|
534
542
|
title = record_title or session.get("title") or "(no title)"
|
|
535
543
|
summary = record_summary or session.get("session_summary") or ""
|
|
@@ -162,7 +162,9 @@ class SessionDetailScreen(ModalScreen):
|
|
|
162
162
|
session_title = getattr(self._session, "session_title", None) if self._session else None
|
|
163
163
|
session_type = getattr(self._session, "session_type", None) if self._session else None
|
|
164
164
|
workspace_path = getattr(self._session, "workspace_path", None) if self._session else None
|
|
165
|
-
last_activity_at =
|
|
165
|
+
last_activity_at = (
|
|
166
|
+
getattr(self._session, "last_activity_at", None) if self._session else None
|
|
167
|
+
)
|
|
166
168
|
started_at = getattr(self._session, "started_at", None) if self._session else None
|
|
167
169
|
|
|
168
170
|
source_map = {
|
|
@@ -74,7 +74,9 @@ class ShareImportScreen(ModalScreen):
|
|
|
74
74
|
placeholder="Paste share URL (e.g. https://.../share/abc123)",
|
|
75
75
|
)
|
|
76
76
|
with Horizontal(classes="row"):
|
|
77
|
-
yield Input(
|
|
77
|
+
yield Input(
|
|
78
|
+
id="share-password", placeholder="Password (optional)", password=True
|
|
79
|
+
)
|
|
78
80
|
with Horizontal(classes="row"):
|
|
79
81
|
yield Checkbox("Force re-import (override duplicates)", id="share-force")
|
|
80
82
|
with Horizontal(id="share-import-actions", classes="row"):
|
|
@@ -107,7 +109,10 @@ class ShareImportScreen(ModalScreen):
|
|
|
107
109
|
self.query_one("#cancel", Button).disabled = busy
|
|
108
110
|
|
|
109
111
|
def _start_import(self) -> None:
|
|
110
|
-
if self._worker is not None and self._worker.state in (
|
|
112
|
+
if self._worker is not None and self._worker.state in (
|
|
113
|
+
WorkerState.PENDING,
|
|
114
|
+
WorkerState.RUNNING,
|
|
115
|
+
):
|
|
111
116
|
return
|
|
112
117
|
|
|
113
118
|
share_url = self.query_one("#share-url", Input).value.strip()
|
|
@@ -181,4 +186,3 @@ class ShareImportScreen(ModalScreen):
|
|
|
181
186
|
message = f"{message}: {stderr_text}"
|
|
182
187
|
status.update(f"[red]{message}[/red]")
|
|
183
188
|
self.app.notify(message, title="Share Import", severity="error", timeout=6)
|
|
184
|
-
|
|
@@ -14,6 +14,7 @@ import shutil
|
|
|
14
14
|
import stat
|
|
15
15
|
import subprocess
|
|
16
16
|
import sys
|
|
17
|
+
import time
|
|
17
18
|
import uuid
|
|
18
19
|
from dataclasses import dataclass
|
|
19
20
|
from pathlib import Path
|
|
@@ -39,6 +40,7 @@ OPT_SESSION_ID = "@aline_session_id"
|
|
|
39
40
|
OPT_TRANSCRIPT_PATH = "@aline_transcript_path"
|
|
40
41
|
OPT_CONTEXT_ID = "@aline_context_id"
|
|
41
42
|
OPT_ATTENTION = "@aline_attention"
|
|
43
|
+
OPT_CREATED_AT = "@aline_created_at"
|
|
42
44
|
|
|
43
45
|
|
|
44
46
|
@dataclass(frozen=True)
|
|
@@ -53,6 +55,7 @@ class InnerWindow:
|
|
|
53
55
|
transcript_path: str | None = None
|
|
54
56
|
context_id: str | None = None
|
|
55
57
|
attention: str | None = None # "permission_request", "stop", or None
|
|
58
|
+
created_at: float | None = None # Unix timestamp when window was created
|
|
56
59
|
|
|
57
60
|
|
|
58
61
|
def tmux_available() -> bool:
|
|
@@ -93,7 +96,9 @@ def _run_tmux(args: Sequence[str], *, capture: bool = False) -> subprocess.Compl
|
|
|
93
96
|
)
|
|
94
97
|
|
|
95
98
|
|
|
96
|
-
def _run_outer_tmux(
|
|
99
|
+
def _run_outer_tmux(
|
|
100
|
+
args: Sequence[str], *, capture: bool = False
|
|
101
|
+
) -> subprocess.CompletedProcess[str]:
|
|
97
102
|
"""Run tmux commands against the dedicated outer server socket."""
|
|
98
103
|
return _run_tmux(["-L", OUTER_SOCKET, *args], capture=capture)
|
|
99
104
|
|
|
@@ -107,7 +112,11 @@ def _run_inner_tmux(
|
|
|
107
112
|
def _python_dashboard_command() -> str:
|
|
108
113
|
# Use the current interpreter for predictable environments (venv, editable installs).
|
|
109
114
|
python_cmd = shlex.join(
|
|
110
|
-
[
|
|
115
|
+
[
|
|
116
|
+
sys.executable,
|
|
117
|
+
"-c",
|
|
118
|
+
"from realign.dashboard.app import run_dashboard; run_dashboard()",
|
|
119
|
+
]
|
|
111
120
|
)
|
|
112
121
|
return f"{MANAGED_ENV}=1 {python_cmd}"
|
|
113
122
|
|
|
@@ -206,7 +215,16 @@ def _aline_tmux_conf_path() -> Path:
|
|
|
206
215
|
def _source_aline_tmux_config(run_fn) -> None: # type: ignore[no-untyped-def]
|
|
207
216
|
"""Best-effort source ~/.aline/tmux/tmux.conf if present."""
|
|
208
217
|
try:
|
|
209
|
-
|
|
218
|
+
# Ensure the config exists and is parseable.
|
|
219
|
+
# Users may run `aline dashboard` before `aline init`, or have older auto-generated configs
|
|
220
|
+
# that included unquoted `#` bindings (tmux treats `#` as a comment delimiter).
|
|
221
|
+
try:
|
|
222
|
+
from ..commands.init import _initialize_tmux_config
|
|
223
|
+
|
|
224
|
+
conf = _initialize_tmux_config()
|
|
225
|
+
except Exception:
|
|
226
|
+
conf = _aline_tmux_conf_path()
|
|
227
|
+
|
|
210
228
|
if conf.exists():
|
|
211
229
|
run_fn(["source-file", str(conf)])
|
|
212
230
|
except Exception:
|
|
@@ -287,7 +305,11 @@ def _maximize_terminal_window() -> None:
|
|
|
287
305
|
|
|
288
306
|
if front_app == "Terminal":
|
|
289
307
|
proc = subprocess.run(
|
|
290
|
-
[
|
|
308
|
+
[
|
|
309
|
+
"osascript",
|
|
310
|
+
"-e",
|
|
311
|
+
'tell application "Terminal" to set zoomed of front window to true',
|
|
312
|
+
],
|
|
291
313
|
capture_output=True,
|
|
292
314
|
text=True,
|
|
293
315
|
timeout=2,
|
|
@@ -374,6 +396,17 @@ def bootstrap_dashboard_into_tmux() -> None:
|
|
|
374
396
|
# Enable mouse for the managed session only.
|
|
375
397
|
_run_outer_tmux(["set-option", "-t", OUTER_SESSION, "mouse", "on"])
|
|
376
398
|
|
|
399
|
+
# Disable status bar for cleaner UI (Aline sessions only).
|
|
400
|
+
_run_outer_tmux(["set-option", "-t", OUTER_SESSION, "status", "off"])
|
|
401
|
+
|
|
402
|
+
# Pane border styling - use double lines for wider, more visible borders.
|
|
403
|
+
# This helps users identify the resizable border area more easily and reduces
|
|
404
|
+
# accidental drag-to-resize when trying to select text near the border.
|
|
405
|
+
_run_outer_tmux(["set-option", "-t", OUTER_SESSION, "pane-border-lines", "double"])
|
|
406
|
+
_run_outer_tmux(["set-option", "-t", OUTER_SESSION, "pane-border-style", "fg=brightblack"])
|
|
407
|
+
_run_outer_tmux(["set-option", "-t", OUTER_SESSION, "pane-active-border-style", "fg=blue"])
|
|
408
|
+
_run_outer_tmux(["set-option", "-t", OUTER_SESSION, "pane-border-indicators", "arrows"])
|
|
409
|
+
|
|
377
410
|
# Ensure dashboard window exists.
|
|
378
411
|
windows_out = (
|
|
379
412
|
_run_outer_tmux(
|
|
@@ -419,6 +452,18 @@ def ensure_inner_session() -> bool:
|
|
|
419
452
|
|
|
420
453
|
# Dedicated inner server; safe to enable mouse globally there.
|
|
421
454
|
_run_inner_tmux(["set-option", "-g", "mouse", "on"])
|
|
455
|
+
|
|
456
|
+
# Disable status bar for cleaner UI.
|
|
457
|
+
_run_inner_tmux(["set-option", "-t", INNER_SESSION, "status", "off"])
|
|
458
|
+
|
|
459
|
+
# Pane border styling - use double lines for wider, more visible borders.
|
|
460
|
+
# This helps users identify the resizable border area more easily and reduces
|
|
461
|
+
# accidental drag-to-resize when trying to select text near the border.
|
|
462
|
+
_run_inner_tmux(["set-option", "-g", "pane-border-lines", "double"])
|
|
463
|
+
_run_inner_tmux(["set-option", "-g", "pane-border-style", "fg=brightblack"])
|
|
464
|
+
_run_inner_tmux(["set-option", "-g", "pane-active-border-style", "fg=blue"])
|
|
465
|
+
_run_inner_tmux(["set-option", "-g", "pane-border-indicators", "arrows"])
|
|
466
|
+
|
|
422
467
|
_source_aline_tmux_config(_run_inner_tmux)
|
|
423
468
|
return True
|
|
424
469
|
|
|
@@ -433,7 +478,13 @@ def ensure_right_pane(width_percent: int = 50) -> bool:
|
|
|
433
478
|
|
|
434
479
|
panes_out = (
|
|
435
480
|
_run_tmux(
|
|
436
|
-
[
|
|
481
|
+
[
|
|
482
|
+
"list-panes",
|
|
483
|
+
"-t",
|
|
484
|
+
f"{OUTER_SESSION}:{OUTER_WINDOW}",
|
|
485
|
+
"-F",
|
|
486
|
+
"#{pane_index}",
|
|
487
|
+
],
|
|
437
488
|
capture=True,
|
|
438
489
|
).stdout
|
|
439
490
|
or ""
|
|
@@ -484,6 +535,8 @@ def list_inner_windows() -> list[InnerWindow]:
|
|
|
484
535
|
+ OPT_CONTEXT_ID
|
|
485
536
|
+ "}\t#{"
|
|
486
537
|
+ OPT_ATTENTION
|
|
538
|
+
+ "}\t#{"
|
|
539
|
+
+ OPT_CREATED_AT
|
|
487
540
|
+ "}",
|
|
488
541
|
],
|
|
489
542
|
capture=True,
|
|
@@ -505,6 +558,13 @@ def list_inner_windows() -> list[InnerWindow]:
|
|
|
505
558
|
transcript_path = parts[7] if len(parts) > 7 and parts[7] else None
|
|
506
559
|
context_id = parts[8] if len(parts) > 8 and parts[8] else None
|
|
507
560
|
attention = parts[9] if len(parts) > 9 and parts[9] else None
|
|
561
|
+
created_at_str = parts[10] if len(parts) > 10 and parts[10] else None
|
|
562
|
+
created_at: float | None = None
|
|
563
|
+
if created_at_str:
|
|
564
|
+
try:
|
|
565
|
+
created_at = float(created_at_str)
|
|
566
|
+
except ValueError:
|
|
567
|
+
pass
|
|
508
568
|
|
|
509
569
|
if terminal_id:
|
|
510
570
|
persisted = state.get(terminal_id) or {}
|
|
@@ -535,8 +595,11 @@ def list_inner_windows() -> list[InnerWindow]:
|
|
|
535
595
|
transcript_path=transcript_path,
|
|
536
596
|
context_id=context_id,
|
|
537
597
|
attention=attention,
|
|
598
|
+
created_at=created_at,
|
|
538
599
|
)
|
|
539
600
|
)
|
|
601
|
+
# Sort by creation time (newest first). Windows without created_at go to the bottom.
|
|
602
|
+
windows.sort(key=lambda w: w.created_at if w.created_at is not None else 0, reverse=True)
|
|
540
603
|
return windows
|
|
541
604
|
|
|
542
605
|
|
|
@@ -551,11 +614,6 @@ def set_inner_window_options(window_id: str, options: dict[str, str]) -> bool:
|
|
|
551
614
|
return ok
|
|
552
615
|
|
|
553
616
|
|
|
554
|
-
def clear_attention(window_id: str) -> bool:
|
|
555
|
-
"""Clear attention state on a window."""
|
|
556
|
-
return set_inner_window_options(window_id, {OPT_ATTENTION: ""})
|
|
557
|
-
|
|
558
|
-
|
|
559
617
|
def kill_inner_window(window_id: str) -> bool:
|
|
560
618
|
if not ensure_inner_session():
|
|
561
619
|
return False
|
|
@@ -576,6 +634,9 @@ def create_inner_window(
|
|
|
576
634
|
existing = list_inner_windows()
|
|
577
635
|
name = _unique_name((w.window_name for w in existing), base_name)
|
|
578
636
|
|
|
637
|
+
# Record creation time before creating the window
|
|
638
|
+
created_at = time.time()
|
|
639
|
+
|
|
579
640
|
proc = _run_inner_tmux(
|
|
580
641
|
[
|
|
581
642
|
"new-window",
|
|
@@ -598,18 +659,18 @@ def create_inner_window(
|
|
|
598
659
|
return None
|
|
599
660
|
window_id, window_name = (created[0].split("\t", 1) + [""])[:2]
|
|
600
661
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
662
|
+
# Always set options including the creation timestamp
|
|
663
|
+
opts: dict[str, str] = {OPT_CREATED_AT: str(created_at)}
|
|
664
|
+
if terminal_id:
|
|
665
|
+
opts[OPT_TERMINAL_ID] = terminal_id
|
|
666
|
+
if provider:
|
|
667
|
+
opts[OPT_PROVIDER] = provider
|
|
668
|
+
if context_id:
|
|
669
|
+
opts[OPT_CONTEXT_ID] = context_id
|
|
670
|
+
opts.setdefault(OPT_SESSION_TYPE, "")
|
|
671
|
+
opts.setdefault(OPT_SESSION_ID, "")
|
|
672
|
+
opts.setdefault(OPT_TRANSCRIPT_PATH, "")
|
|
673
|
+
set_inner_window_options(window_id, opts)
|
|
613
674
|
|
|
614
675
|
_run_inner_tmux(["select-window", "-t", window_id])
|
|
615
676
|
|
|
@@ -620,6 +681,7 @@ def create_inner_window(
|
|
|
620
681
|
terminal_id=terminal_id,
|
|
621
682
|
provider=provider,
|
|
622
683
|
context_id=context_id,
|
|
684
|
+
created_at=created_at,
|
|
623
685
|
)
|
|
624
686
|
|
|
625
687
|
|
|
@@ -629,6 +691,13 @@ def select_inner_window(window_id: str) -> bool:
|
|
|
629
691
|
return _run_inner_tmux(["select-window", "-t", window_id]).returncode == 0
|
|
630
692
|
|
|
631
693
|
|
|
694
|
+
def clear_attention(window_id: str) -> bool:
|
|
695
|
+
"""Clear the attention state for a window (e.g., after user acknowledges permission request)."""
|
|
696
|
+
if not ensure_inner_session():
|
|
697
|
+
return False
|
|
698
|
+
return _run_inner_tmux(["set-option", "-w", "-t", window_id, OPT_ATTENTION, ""]).returncode == 0
|
|
699
|
+
|
|
700
|
+
|
|
632
701
|
def get_active_claude_context_id() -> str | None:
|
|
633
702
|
"""Return the active inner tmux window's Claude ALINE_CONTEXT_ID (if any)."""
|
|
634
703
|
try:
|