aline-ai 0.7.3__py3-none-any.whl → 0.7.4__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.7.3.dist-info → aline_ai-0.7.4.dist-info}/METADATA +1 -1
- {aline_ai-0.7.3.dist-info → aline_ai-0.7.4.dist-info}/RECORD +31 -26
- realign/__init__.py +1 -1
- realign/adapters/codex.py +30 -2
- realign/claude_hooks/stop_hook.py +176 -21
- realign/codex_home.py +71 -0
- realign/codex_hooks/__init__.py +16 -0
- realign/codex_hooks/notify_hook.py +511 -0
- realign/codex_hooks/notify_hook_installer.py +247 -0
- realign/commands/doctor.py +125 -0
- realign/commands/import_shares.py +30 -10
- realign/commands/init.py +16 -0
- realign/commands/sync_agent.py +230 -52
- realign/commit_pipeline.py +1024 -0
- realign/config.py +3 -11
- realign/dashboard/app.py +150 -0
- realign/dashboard/diagnostics.py +274 -0
- realign/dashboard/screens/create_agent.py +2 -1
- realign/dashboard/screens/create_agent_info.py +40 -77
- realign/dashboard/tmux_manager.py +334 -20
- realign/dashboard/widgets/agents_panel.py +812 -202
- realign/dashboard/widgets/config_panel.py +34 -121
- realign/dashboard/widgets/header.py +1 -1
- realign/db/sqlite_db.py +59 -1
- realign/logging_config.py +51 -6
- realign/watcher_core.py +742 -393
- realign/worker_core.py +206 -15
- {aline_ai-0.7.3.dist-info → aline_ai-0.7.4.dist-info}/WHEEL +0 -0
- {aline_ai-0.7.3.dist-info → aline_ai-0.7.4.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.7.3.dist-info → aline_ai-0.7.4.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.7.3.dist-info → aline_ai-0.7.4.dist-info}/top_level.txt +0 -0
|
@@ -7,7 +7,7 @@ from typing import Optional
|
|
|
7
7
|
|
|
8
8
|
from textual.app import ComposeResult
|
|
9
9
|
from textual.binding import Binding
|
|
10
|
-
from textual.containers import Container, Horizontal
|
|
10
|
+
from textual.containers import Container, Horizontal
|
|
11
11
|
from textual.screen import ModalScreen
|
|
12
12
|
from textual.widgets import Button, Input, Label, Static
|
|
13
13
|
from textual.worker import Worker, WorkerState
|
|
@@ -34,7 +34,8 @@ class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
CreateAgentInfoScreen #create-agent-info-root {
|
|
37
|
-
width:
|
|
37
|
+
width: 100%;
|
|
38
|
+
max-width: 65;
|
|
38
39
|
height: auto;
|
|
39
40
|
max-height: 80%;
|
|
40
41
|
padding: 1 2;
|
|
@@ -57,15 +58,6 @@ class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
|
|
|
57
58
|
|
|
58
59
|
CreateAgentInfoScreen Input {
|
|
59
60
|
margin-top: 0;
|
|
60
|
-
border: none;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
CreateAgentInfoScreen #or-separator {
|
|
64
|
-
height: auto;
|
|
65
|
-
margin-top: 1;
|
|
66
|
-
margin-bottom: 0;
|
|
67
|
-
text-align: center;
|
|
68
|
-
color: $text-muted;
|
|
69
61
|
}
|
|
70
62
|
|
|
71
63
|
CreateAgentInfoScreen #import-status {
|
|
@@ -74,23 +66,13 @@ class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
|
|
|
74
66
|
color: $text-muted;
|
|
75
67
|
}
|
|
76
68
|
|
|
77
|
-
CreateAgentInfoScreen #
|
|
69
|
+
CreateAgentInfoScreen #add-buttons {
|
|
78
70
|
height: auto;
|
|
79
71
|
margin-top: 1;
|
|
80
72
|
align: right middle;
|
|
81
73
|
}
|
|
82
74
|
|
|
83
|
-
CreateAgentInfoScreen #
|
|
84
|
-
height: auto;
|
|
85
|
-
margin-top: 1;
|
|
86
|
-
align: right middle;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
CreateAgentInfoScreen #create-buttons Button {
|
|
90
|
-
margin-left: 1;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
CreateAgentInfoScreen #import-buttons Button {
|
|
75
|
+
CreateAgentInfoScreen #add-buttons Button {
|
|
94
76
|
margin-left: 1;
|
|
95
77
|
}
|
|
96
78
|
"""
|
|
@@ -103,44 +85,37 @@ class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
|
|
|
103
85
|
|
|
104
86
|
def compose(self) -> ComposeResult:
|
|
105
87
|
with Container(id="create-agent-info-root"):
|
|
106
|
-
yield Static("
|
|
107
|
-
|
|
108
|
-
# --- Create New section ---
|
|
109
|
-
yield Label("Name", classes="section-label")
|
|
110
|
-
yield Input(placeholder=self._default_name, id="agent-name")
|
|
88
|
+
yield Static("Add Agent", id="create-agent-info-title")
|
|
111
89
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
yield Button("Create", id="create", variant="primary")
|
|
115
|
-
|
|
116
|
-
# --- Separator ---
|
|
117
|
-
yield Static("-- Or --", id="or-separator")
|
|
118
|
-
|
|
119
|
-
# --- Import from Link section ---
|
|
120
|
-
yield Label("Import from Link", classes="section-label")
|
|
121
|
-
yield Input(placeholder="https://realign-server.vercel.app/share/...", id="share-url")
|
|
122
|
-
|
|
123
|
-
yield Label("Password (optional)", classes="section-label")
|
|
124
|
-
yield Input(placeholder="Leave blank if not password-protected", id="share-password", password=True)
|
|
90
|
+
yield Label("Name or Share Link", classes="section-label")
|
|
91
|
+
yield Input(placeholder=self._default_name, id="agent-input")
|
|
125
92
|
|
|
126
93
|
yield Static("", id="import-status")
|
|
127
94
|
|
|
128
|
-
with Horizontal(id="
|
|
129
|
-
yield Button("
|
|
95
|
+
with Horizontal(id="add-buttons"):
|
|
96
|
+
yield Button("Cancel", id="cancel")
|
|
97
|
+
yield Button("Add", id="add", variant="primary")
|
|
130
98
|
|
|
131
99
|
def on_mount(self) -> None:
|
|
132
|
-
self.query_one("#agent-
|
|
100
|
+
agent_input = self.query_one("#agent-input", Input)
|
|
101
|
+
agent_input.styles.border = ("round", "black")
|
|
102
|
+
agent_input.focus()
|
|
103
|
+
for btn in (self.query_one("#cancel", Button), self.query_one("#add", Button)):
|
|
104
|
+
btn.styles.border = ("round", "black")
|
|
105
|
+
btn.styles.text_align = "center"
|
|
106
|
+
btn.styles.content_align = ("center", "middle")
|
|
133
107
|
|
|
134
108
|
def action_close(self) -> None:
|
|
135
109
|
self.dismiss(None)
|
|
136
110
|
|
|
137
111
|
def _set_busy(self, busy: bool) -> None:
|
|
138
|
-
self.query_one("#agent-
|
|
139
|
-
self.query_one("#
|
|
140
|
-
self.query_one("#share-password", Input).disabled = busy
|
|
141
|
-
self.query_one("#create", Button).disabled = busy
|
|
112
|
+
self.query_one("#agent-input", Input).disabled = busy
|
|
113
|
+
self.query_one("#add", Button).disabled = busy
|
|
142
114
|
self.query_one("#cancel", Button).disabled = busy
|
|
143
|
-
|
|
115
|
+
|
|
116
|
+
def _is_share_link(self, value: str) -> bool:
|
|
117
|
+
"""Check if the input looks like a share link."""
|
|
118
|
+
return "/share/" in value
|
|
144
119
|
|
|
145
120
|
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
146
121
|
button_id = event.button.id or ""
|
|
@@ -149,28 +124,29 @@ class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
|
|
|
149
124
|
self.dismiss(None)
|
|
150
125
|
return
|
|
151
126
|
|
|
152
|
-
if button_id == "
|
|
153
|
-
await self.
|
|
154
|
-
return
|
|
155
|
-
|
|
156
|
-
if button_id == "import":
|
|
157
|
-
await self._import_agent()
|
|
127
|
+
if button_id == "add":
|
|
128
|
+
await self._add_agent()
|
|
158
129
|
return
|
|
159
130
|
|
|
160
131
|
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
161
132
|
"""Handle enter key in input fields."""
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
await self._create_agent()
|
|
165
|
-
elif input_id in ("share-url", "share-password"):
|
|
166
|
-
await self._import_agent()
|
|
133
|
+
if event.input.id == "agent-input":
|
|
134
|
+
await self._add_agent()
|
|
167
135
|
|
|
168
|
-
async def
|
|
136
|
+
async def _add_agent(self) -> None:
|
|
137
|
+
"""Create a new agent or import from link based on input content."""
|
|
138
|
+
value = self.query_one("#agent-input", Input).value.strip()
|
|
139
|
+
|
|
140
|
+
if self._is_share_link(value):
|
|
141
|
+
await self._import_agent(value)
|
|
142
|
+
else:
|
|
143
|
+
await self._create_agent(value)
|
|
144
|
+
|
|
145
|
+
async def _create_agent(self, name_input: str) -> None:
|
|
169
146
|
"""Create the agent profile."""
|
|
170
147
|
try:
|
|
171
148
|
from ...db import get_database
|
|
172
149
|
|
|
173
|
-
name_input = self.query_one("#agent-name", Input).value.strip()
|
|
174
150
|
name = name_input or self._default_name
|
|
175
151
|
|
|
176
152
|
agent_id = str(uuid.uuid4())
|
|
@@ -187,21 +163,8 @@ class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
|
|
|
187
163
|
logger.error(f"Failed to create agent: {e}")
|
|
188
164
|
self.app.notify(f"Failed to create agent: {e}", severity="error")
|
|
189
165
|
|
|
190
|
-
async def _import_agent(self) -> None:
|
|
166
|
+
async def _import_agent(self, share_url: str) -> None:
|
|
191
167
|
"""Import an agent from a share link."""
|
|
192
|
-
share_url = self.query_one("#share-url", Input).value.strip()
|
|
193
|
-
password = self.query_one("#share-password", Input).value.strip() or None
|
|
194
|
-
|
|
195
|
-
if not share_url:
|
|
196
|
-
self.app.notify("Please enter a share URL", severity="warning")
|
|
197
|
-
self.query_one("#share-url", Input).focus()
|
|
198
|
-
return
|
|
199
|
-
|
|
200
|
-
if "/share/" not in share_url:
|
|
201
|
-
self.app.notify("Invalid share URL format", severity="warning")
|
|
202
|
-
self.query_one("#share-url", Input).focus()
|
|
203
|
-
return
|
|
204
|
-
|
|
205
168
|
status = self.query_one("#import-status", Static)
|
|
206
169
|
status.update("Importing...")
|
|
207
170
|
self._set_busy(True)
|
|
@@ -209,7 +172,7 @@ class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
|
|
|
209
172
|
def do_import() -> dict:
|
|
210
173
|
from ...commands.import_shares import import_agent_from_share
|
|
211
174
|
|
|
212
|
-
return import_agent_from_share(share_url, password=
|
|
175
|
+
return import_agent_from_share(share_url, password=None)
|
|
213
176
|
|
|
214
177
|
self._import_worker = self.run_worker(do_import, thread=True, exit_on_error=False)
|
|
215
178
|
|
|
@@ -14,6 +14,7 @@ import shutil
|
|
|
14
14
|
import stat
|
|
15
15
|
import subprocess
|
|
16
16
|
import sys
|
|
17
|
+
import threading
|
|
17
18
|
import time
|
|
18
19
|
import traceback
|
|
19
20
|
import uuid
|
|
@@ -47,6 +48,10 @@ OPT_ATTENTION = "@aline_attention"
|
|
|
47
48
|
OPT_CREATED_AT = "@aline_created_at"
|
|
48
49
|
OPT_NO_TRACK = "@aline_no_track"
|
|
49
50
|
|
|
51
|
+
# Default outer layout preferences (tmux mode).
|
|
52
|
+
# Note: tmux "pixels" are terminal cell columns; Textual has no notion of pixels.
|
|
53
|
+
DEFAULT_DASHBOARD_PANE_WIDTH_COLS = 45
|
|
54
|
+
|
|
50
55
|
|
|
51
56
|
@dataclass(frozen=True)
|
|
52
57
|
class InnerWindow:
|
|
@@ -62,6 +67,9 @@ class InnerWindow:
|
|
|
62
67
|
attention: str | None = None # "permission_request", "stop", or None
|
|
63
68
|
created_at: float | None = None # Unix timestamp when window was created
|
|
64
69
|
no_track: bool = False # Whether tracking is disabled for this terminal
|
|
70
|
+
pane_pid: int | None = None # PID of the initial process in the pane
|
|
71
|
+
pane_current_command: str | None = None # Foreground process in the pane
|
|
72
|
+
pane_tty: str | None = None # Controlling TTY for processes in the pane
|
|
65
73
|
|
|
66
74
|
|
|
67
75
|
def tmux_available() -> bool:
|
|
@@ -93,26 +101,31 @@ def tmux_version() -> tuple[int, int] | None:
|
|
|
93
101
|
return int(match.group(1)), int(match.group(2))
|
|
94
102
|
|
|
95
103
|
|
|
96
|
-
def _run_tmux(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
104
|
+
def _run_tmux(
|
|
105
|
+
args: Sequence[str], *, capture: bool = False, timeout_s: float | None = None
|
|
106
|
+
) -> subprocess.CompletedProcess[str]:
|
|
107
|
+
kwargs: dict[str, object] = {
|
|
108
|
+
"text": True,
|
|
109
|
+
"capture_output": capture,
|
|
110
|
+
"check": False,
|
|
111
|
+
}
|
|
112
|
+
# Keep keyword list minimal for test fakes that don't accept extra kwargs.
|
|
113
|
+
if timeout_s is not None:
|
|
114
|
+
kwargs["timeout"] = timeout_s
|
|
115
|
+
return subprocess.run(["tmux", *args], **kwargs) # type: ignore[arg-type]
|
|
103
116
|
|
|
104
117
|
|
|
105
118
|
def _run_outer_tmux(
|
|
106
|
-
args: Sequence[str], *, capture: bool = False
|
|
119
|
+
args: Sequence[str], *, capture: bool = False, timeout_s: float | None = None
|
|
107
120
|
) -> subprocess.CompletedProcess[str]:
|
|
108
121
|
"""Run tmux commands against the dedicated outer server socket."""
|
|
109
|
-
return _run_tmux(["-L", OUTER_SOCKET, *args], capture=capture)
|
|
122
|
+
return _run_tmux(["-L", OUTER_SOCKET, *args], capture=capture, timeout_s=timeout_s)
|
|
110
123
|
|
|
111
124
|
|
|
112
125
|
def _run_inner_tmux(
|
|
113
|
-
args: Sequence[str], *, capture: bool = False
|
|
126
|
+
args: Sequence[str], *, capture: bool = False, timeout_s: float | None = None
|
|
114
127
|
) -> subprocess.CompletedProcess[str]:
|
|
115
|
-
return _run_tmux(["-L", INNER_SOCKET, *args], capture=capture)
|
|
128
|
+
return _run_tmux(["-L", INNER_SOCKET, *args], capture=capture, timeout_s=timeout_s)
|
|
116
129
|
|
|
117
130
|
|
|
118
131
|
def _python_dashboard_command() -> str:
|
|
@@ -256,6 +269,12 @@ def _load_terminal_state_from_json() -> dict[str, dict[str, str]]:
|
|
|
256
269
|
return {}
|
|
257
270
|
|
|
258
271
|
|
|
272
|
+
_TERMINAL_STATE_CACHE_LOCK = threading.Lock()
|
|
273
|
+
_TERMINAL_STATE_CACHE: dict[str, dict[str, str]] | None = None
|
|
274
|
+
_TERMINAL_STATE_CACHE_AT: float = 0.0
|
|
275
|
+
_TERMINAL_STATE_CACHE_TTL_S: float = 1.5
|
|
276
|
+
|
|
277
|
+
|
|
259
278
|
def _load_terminal_state() -> dict[str, dict[str, str]]:
|
|
260
279
|
"""Load terminal state.
|
|
261
280
|
|
|
@@ -265,6 +284,13 @@ def _load_terminal_state() -> dict[str, dict[str, str]]:
|
|
|
265
284
|
|
|
266
285
|
Merges both sources, with DB taking precedence.
|
|
267
286
|
"""
|
|
287
|
+
global _TERMINAL_STATE_CACHE, _TERMINAL_STATE_CACHE_AT
|
|
288
|
+
now = time.monotonic()
|
|
289
|
+
with _TERMINAL_STATE_CACHE_LOCK:
|
|
290
|
+
cache = _TERMINAL_STATE_CACHE
|
|
291
|
+
if cache is not None and (now - _TERMINAL_STATE_CACHE_AT) <= _TERMINAL_STATE_CACHE_TTL_S:
|
|
292
|
+
return dict(cache)
|
|
293
|
+
|
|
268
294
|
# Phase 1: Load from database
|
|
269
295
|
db_state = _load_terminal_state_from_db()
|
|
270
296
|
|
|
@@ -275,6 +301,10 @@ def _load_terminal_state() -> dict[str, dict[str, str]]:
|
|
|
275
301
|
result = dict(json_state)
|
|
276
302
|
result.update(db_state)
|
|
277
303
|
|
|
304
|
+
with _TERMINAL_STATE_CACHE_LOCK:
|
|
305
|
+
_TERMINAL_STATE_CACHE = dict(result)
|
|
306
|
+
_TERMINAL_STATE_CACHE_AT = time.monotonic()
|
|
307
|
+
|
|
278
308
|
return result
|
|
279
309
|
|
|
280
310
|
|
|
@@ -471,6 +501,7 @@ def bootstrap_dashboard_into_tmux() -> None:
|
|
|
471
501
|
|
|
472
502
|
# Enable mouse for the managed session only.
|
|
473
503
|
_run_outer_tmux(["set-option", "-t", OUTER_SESSION, "mouse", "on"])
|
|
504
|
+
_disable_outer_border_resize()
|
|
474
505
|
|
|
475
506
|
# Disable status bar for cleaner UI (Aline sessions only).
|
|
476
507
|
_run_outer_tmux(["set-option", "-t", OUTER_SESSION, "status", "off"])
|
|
@@ -506,6 +537,9 @@ def bootstrap_dashboard_into_tmux() -> None:
|
|
|
506
537
|
)
|
|
507
538
|
_run_outer_tmux(["select-window", "-t", f"{OUTER_SESSION}:{OUTER_WINDOW}"])
|
|
508
539
|
|
|
540
|
+
# Best-effort: enforce a stable dashboard pane width if the terminal pane already exists.
|
|
541
|
+
_set_outer_dashboard_pane_width(DEFAULT_DASHBOARD_PANE_WIDTH_COLS)
|
|
542
|
+
|
|
509
543
|
# Sanity-check before exec'ing into tmux attach. If this fails, fall back to non-tmux mode.
|
|
510
544
|
ready = _run_outer_tmux(["has-session", "-t", OUTER_SESSION], capture=True)
|
|
511
545
|
if ready.returncode != 0:
|
|
@@ -691,6 +725,7 @@ def ensure_right_pane(width_percent: int = 50) -> bool:
|
|
|
691
725
|
)
|
|
692
726
|
panes = _parse_lines(panes_out)
|
|
693
727
|
if len(panes) >= 2:
|
|
728
|
+
_set_outer_dashboard_pane_width(DEFAULT_DASHBOARD_PANE_WIDTH_COLS)
|
|
694
729
|
return True
|
|
695
730
|
|
|
696
731
|
# Split from the dashboard pane to keep it on the left.
|
|
@@ -705,9 +740,144 @@ def ensure_right_pane(width_percent: int = 50) -> bool:
|
|
|
705
740
|
f"{OUTER_SESSION}:{OUTER_WINDOW}.0",
|
|
706
741
|
"-d",
|
|
707
742
|
attach_cmd,
|
|
708
|
-
]
|
|
743
|
+
],
|
|
744
|
+
capture=True,
|
|
709
745
|
)
|
|
710
|
-
|
|
746
|
+
if split.returncode != 0:
|
|
747
|
+
detail = (split.stderr or split.stdout or "").strip()
|
|
748
|
+
if detail:
|
|
749
|
+
logger.warning(f"ensure_right_pane split-window failed: {detail}")
|
|
750
|
+
if split.returncode == 0:
|
|
751
|
+
_set_outer_dashboard_pane_width(DEFAULT_DASHBOARD_PANE_WIDTH_COLS)
|
|
752
|
+
return True
|
|
753
|
+
return False
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def ensure_right_pane_ready(width_percent: int = 50) -> bool:
|
|
757
|
+
"""Ensure the right pane exists and is attached to the inner tmux session."""
|
|
758
|
+
try:
|
|
759
|
+
ok = ensure_right_pane(width_percent)
|
|
760
|
+
except TypeError:
|
|
761
|
+
# Tests and some callers monkeypatch ensure_right_pane() as a no-arg lambda.
|
|
762
|
+
ok = ensure_right_pane()
|
|
763
|
+
if not ok:
|
|
764
|
+
return False
|
|
765
|
+
|
|
766
|
+
# Best-effort: enforce a stable dashboard pane width whenever we touch the outer layout.
|
|
767
|
+
_set_outer_dashboard_pane_width(DEFAULT_DASHBOARD_PANE_WIDTH_COLS)
|
|
768
|
+
|
|
769
|
+
attach_cmd = shlex.join(["tmux", "-L", INNER_SOCKET, "attach", "-t", INNER_SESSION])
|
|
770
|
+
|
|
771
|
+
# If the right pane exists but isn't running `tmux attach`, it may look "blank" or stale.
|
|
772
|
+
try:
|
|
773
|
+
proc = _run_outer_tmux(
|
|
774
|
+
[
|
|
775
|
+
"display-message",
|
|
776
|
+
"-p",
|
|
777
|
+
"-t",
|
|
778
|
+
f"{OUTER_SESSION}:{OUTER_WINDOW}.1",
|
|
779
|
+
"#{pane_current_command}",
|
|
780
|
+
],
|
|
781
|
+
capture=True,
|
|
782
|
+
)
|
|
783
|
+
current_cmd = (proc.stdout or "").strip()
|
|
784
|
+
except Exception:
|
|
785
|
+
current_cmd = ""
|
|
786
|
+
|
|
787
|
+
if current_cmd and current_cmd != "tmux":
|
|
788
|
+
respawn = _run_outer_tmux(
|
|
789
|
+
[
|
|
790
|
+
"respawn-pane",
|
|
791
|
+
"-k",
|
|
792
|
+
"-t",
|
|
793
|
+
f"{OUTER_SESSION}:{OUTER_WINDOW}.1",
|
|
794
|
+
attach_cmd,
|
|
795
|
+
],
|
|
796
|
+
capture=True,
|
|
797
|
+
)
|
|
798
|
+
if respawn.returncode != 0:
|
|
799
|
+
detail = (respawn.stderr or respawn.stdout or "").strip()
|
|
800
|
+
if detail:
|
|
801
|
+
logger.warning(f"ensure_right_pane_ready respawn failed: {detail}")
|
|
802
|
+
return False
|
|
803
|
+
|
|
804
|
+
return True
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _disable_outer_border_resize() -> None:
|
|
808
|
+
"""Disable mouse drag-to-resize on pane borders (outer dashboard tmux server only)."""
|
|
809
|
+
try:
|
|
810
|
+
# tmux enables border resizing via MouseDrag1Border. Unbind it on our dedicated server
|
|
811
|
+
# to avoid accidental resizing when selecting text near the pane divider.
|
|
812
|
+
for key in ("MouseDrag1Border", "MouseDown1Border", "MouseDragEnd1Border", "MouseUp1Border"):
|
|
813
|
+
_run_outer_tmux(["unbind-key", "-T", "root", key], capture=True)
|
|
814
|
+
except Exception:
|
|
815
|
+
return
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _set_outer_dashboard_pane_width(width_cols: int) -> None:
|
|
819
|
+
"""Best-effort: size the left dashboard pane to a fixed width (in terminal columns)."""
|
|
820
|
+
try:
|
|
821
|
+
w = int(width_cols)
|
|
822
|
+
if w <= 0:
|
|
823
|
+
return
|
|
824
|
+
except Exception:
|
|
825
|
+
return
|
|
826
|
+
|
|
827
|
+
try:
|
|
828
|
+
# Avoid assuming pane indexes are 0/1. Determine the leftmost pane by geometry.
|
|
829
|
+
panes_out = (
|
|
830
|
+
_run_outer_tmux(
|
|
831
|
+
[
|
|
832
|
+
"list-panes",
|
|
833
|
+
"-t",
|
|
834
|
+
f"{OUTER_SESSION}:{OUTER_WINDOW}",
|
|
835
|
+
"-F",
|
|
836
|
+
"#{pane_id}\t#{pane_left}\t#{pane_index}",
|
|
837
|
+
],
|
|
838
|
+
capture=True,
|
|
839
|
+
timeout_s=0.2,
|
|
840
|
+
).stdout
|
|
841
|
+
or ""
|
|
842
|
+
)
|
|
843
|
+
panes: list[tuple[int, int, str]] = []
|
|
844
|
+
for line in _parse_lines(panes_out):
|
|
845
|
+
parts = line.split("\t")
|
|
846
|
+
if len(parts) < 2:
|
|
847
|
+
continue
|
|
848
|
+
pane_id = (parts[0] or "").strip()
|
|
849
|
+
if not pane_id:
|
|
850
|
+
continue
|
|
851
|
+
try:
|
|
852
|
+
pane_left = int((parts[1] or "0").strip())
|
|
853
|
+
except Exception:
|
|
854
|
+
continue
|
|
855
|
+
pane_index = 0
|
|
856
|
+
if len(parts) > 2 and (parts[2] or "").strip():
|
|
857
|
+
try:
|
|
858
|
+
pane_index = int(parts[2].strip())
|
|
859
|
+
except Exception:
|
|
860
|
+
pane_index = 0
|
|
861
|
+
panes.append((pane_left, pane_index, pane_id))
|
|
862
|
+
|
|
863
|
+
# Only enforce widths when there are two panes (dashboard + terminal).
|
|
864
|
+
if len(panes) < 2:
|
|
865
|
+
return
|
|
866
|
+
|
|
867
|
+
panes.sort(key=lambda t: (t[0], t[1]))
|
|
868
|
+
leftmost_pane_id = panes[0][2]
|
|
869
|
+
_run_outer_tmux(
|
|
870
|
+
["resize-pane", "-t", leftmost_pane_id, "-x", str(w)],
|
|
871
|
+
capture=True,
|
|
872
|
+
timeout_s=0.2,
|
|
873
|
+
)
|
|
874
|
+
except Exception:
|
|
875
|
+
return
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def enforce_outer_dashboard_pane_width(width_cols: int = DEFAULT_DASHBOARD_PANE_WIDTH_COLS) -> None:
|
|
879
|
+
"""Best-effort: enforce the fixed dashboard pane width for the outer tmux layout."""
|
|
880
|
+
_set_outer_dashboard_pane_width(width_cols)
|
|
711
881
|
|
|
712
882
|
|
|
713
883
|
def list_inner_windows() -> list[InnerWindow]:
|
|
@@ -745,7 +915,7 @@ def list_inner_windows() -> list[InnerWindow]:
|
|
|
745
915
|
+ OPT_CREATED_AT
|
|
746
916
|
+ "}\t#{"
|
|
747
917
|
+ OPT_NO_TRACK
|
|
748
|
-
+ "}",
|
|
918
|
+
+ "}\t#{pane_pid}\t#{pane_current_command}\t#{pane_tty}",
|
|
749
919
|
],
|
|
750
920
|
capture=True,
|
|
751
921
|
).stdout
|
|
@@ -775,6 +945,15 @@ def list_inner_windows() -> list[InnerWindow]:
|
|
|
775
945
|
pass
|
|
776
946
|
no_track_str = parts[11] if len(parts) > 11 and parts[11] else None
|
|
777
947
|
no_track = no_track_str == "1"
|
|
948
|
+
pane_pid_str = parts[12] if len(parts) > 12 and parts[12] else None
|
|
949
|
+
pane_pid: int | None = None
|
|
950
|
+
if pane_pid_str:
|
|
951
|
+
try:
|
|
952
|
+
pane_pid = int(pane_pid_str)
|
|
953
|
+
except ValueError:
|
|
954
|
+
pass
|
|
955
|
+
pane_current_command = parts[13] if len(parts) > 13 and parts[13] else None
|
|
956
|
+
pane_tty = parts[14] if len(parts) > 14 and parts[14] else None
|
|
778
957
|
|
|
779
958
|
if terminal_id:
|
|
780
959
|
persisted = state.get(terminal_id) or {}
|
|
@@ -833,6 +1012,9 @@ def list_inner_windows() -> list[InnerWindow]:
|
|
|
833
1012
|
attention=attention,
|
|
834
1013
|
created_at=created_at,
|
|
835
1014
|
no_track=no_track,
|
|
1015
|
+
pane_pid=pane_pid,
|
|
1016
|
+
pane_current_command=pane_current_command,
|
|
1017
|
+
pane_tty=pane_tty,
|
|
836
1018
|
)
|
|
837
1019
|
)
|
|
838
1020
|
# Sort by creation time (newest first). Windows without created_at go to the bottom.
|
|
@@ -840,6 +1022,33 @@ def list_inner_windows() -> list[InnerWindow]:
|
|
|
840
1022
|
return windows
|
|
841
1023
|
|
|
842
1024
|
|
|
1025
|
+
def list_outer_panes(*, timeout_s: float = 0.2) -> list[str]:
|
|
1026
|
+
"""List panes in the outer dashboard window (best-effort).
|
|
1027
|
+
|
|
1028
|
+
Intended for lightweight watchdog checks; returns tab-delimited lines in the same
|
|
1029
|
+
format as `collect_tmux_debug_state()["outer_panes"]["stdout"]`.
|
|
1030
|
+
"""
|
|
1031
|
+
if not tmux_available():
|
|
1032
|
+
return []
|
|
1033
|
+
try:
|
|
1034
|
+
proc = _run_outer_tmux(
|
|
1035
|
+
[
|
|
1036
|
+
"list-panes",
|
|
1037
|
+
"-t",
|
|
1038
|
+
f"{OUTER_SESSION}:{OUTER_WINDOW}",
|
|
1039
|
+
"-F",
|
|
1040
|
+
"#{pane_index}\t#{pane_active}\t#{pane_current_command}\t#{pane_pid}\t#{pane_tty}",
|
|
1041
|
+
],
|
|
1042
|
+
capture=True,
|
|
1043
|
+
timeout_s=timeout_s,
|
|
1044
|
+
)
|
|
1045
|
+
except Exception:
|
|
1046
|
+
return []
|
|
1047
|
+
if proc.returncode != 0:
|
|
1048
|
+
return []
|
|
1049
|
+
return [ln for ln in (proc.stdout or "").splitlines() if ln.strip()]
|
|
1050
|
+
|
|
1051
|
+
|
|
843
1052
|
def set_inner_window_options(window_id: str, options: dict[str, str]) -> bool:
|
|
844
1053
|
import time as _time
|
|
845
1054
|
|
|
@@ -874,7 +1083,8 @@ def create_inner_window(
|
|
|
874
1083
|
|
|
875
1084
|
t0 = _time.time()
|
|
876
1085
|
logger.debug("[PERF] create_inner_window START")
|
|
877
|
-
if not
|
|
1086
|
+
if not ensure_right_pane_ready():
|
|
1087
|
+
logger.warning("create_inner_window: right pane unavailable")
|
|
878
1088
|
return None
|
|
879
1089
|
logger.debug(f"[PERF] create_inner_window ensure_right_pane: {_time.time() - t0:.3f}s")
|
|
880
1090
|
|
|
@@ -903,6 +1113,9 @@ def create_inner_window(
|
|
|
903
1113
|
)
|
|
904
1114
|
logger.debug(f"[PERF] create_inner_window new-window: {_time.time() - t2:.3f}s")
|
|
905
1115
|
if proc.returncode != 0:
|
|
1116
|
+
detail = (proc.stderr or proc.stdout or "").strip()
|
|
1117
|
+
if detail:
|
|
1118
|
+
logger.warning(f"create_inner_window new-window failed: {detail}")
|
|
906
1119
|
return None
|
|
907
1120
|
|
|
908
1121
|
created = _parse_lines(proc.stdout or "")
|
|
@@ -943,16 +1156,26 @@ def create_inner_window(
|
|
|
943
1156
|
|
|
944
1157
|
|
|
945
1158
|
def select_inner_window(window_id: str) -> bool:
|
|
946
|
-
if not
|
|
1159
|
+
if not ensure_right_pane_ready():
|
|
1160
|
+
return False
|
|
1161
|
+
proc = _run_inner_tmux(["select-window", "-t", window_id], capture=True)
|
|
1162
|
+
if proc.returncode != 0:
|
|
1163
|
+
detail = (proc.stderr or proc.stdout or "").strip()
|
|
1164
|
+
if detail:
|
|
1165
|
+
logger.warning(f"select_inner_window failed ({window_id}): {detail}")
|
|
947
1166
|
return False
|
|
948
|
-
return
|
|
1167
|
+
return True
|
|
949
1168
|
|
|
950
1169
|
|
|
951
1170
|
def focus_right_pane() -> bool:
|
|
952
1171
|
"""Focus the right pane (terminal area) in the outer tmux layout."""
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
1172
|
+
proc = _run_outer_tmux(["select-pane", "-t", f"{OUTER_SESSION}:{OUTER_WINDOW}.1"], capture=True)
|
|
1173
|
+
if proc.returncode != 0:
|
|
1174
|
+
detail = (proc.stderr or proc.stdout or "").strip()
|
|
1175
|
+
if detail:
|
|
1176
|
+
logger.warning(f"focus_right_pane failed: {detail}")
|
|
1177
|
+
return False
|
|
1178
|
+
return True
|
|
956
1179
|
|
|
957
1180
|
|
|
958
1181
|
def clear_attention(window_id: str) -> bool:
|
|
@@ -992,3 +1215,94 @@ def get_active_context_id(*, allowed_providers: set[str] | None = None) -> str |
|
|
|
992
1215
|
|
|
993
1216
|
context_id = (active.context_id or "").strip()
|
|
994
1217
|
return context_id or None
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def collect_tmux_debug_state() -> dict[str, object]:
|
|
1221
|
+
"""Best-effort snapshot of tmux state for diagnosing blank/stuck panes.
|
|
1222
|
+
|
|
1223
|
+
This must be non-intrusive: it should not create sessions or change state.
|
|
1224
|
+
"""
|
|
1225
|
+
|
|
1226
|
+
def _cap(proc: subprocess.CompletedProcess[str] | None) -> dict[str, object]:
|
|
1227
|
+
if proc is None:
|
|
1228
|
+
return {}
|
|
1229
|
+
|
|
1230
|
+
def _trim(s: str | None) -> str:
|
|
1231
|
+
text = (s or "").strip()
|
|
1232
|
+
if len(text) > 4000:
|
|
1233
|
+
return text[:4000] + "…(truncated)"
|
|
1234
|
+
return text
|
|
1235
|
+
|
|
1236
|
+
return {
|
|
1237
|
+
"rc": int(getattr(proc, "returncode", -1)),
|
|
1238
|
+
"stdout": _trim(getattr(proc, "stdout", "")),
|
|
1239
|
+
"stderr": _trim(getattr(proc, "stderr", "")),
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
state: dict[str, object] = {
|
|
1243
|
+
"tmux_available": tmux_available(),
|
|
1244
|
+
"in_tmux": in_tmux(),
|
|
1245
|
+
"managed_env": managed_env_enabled(),
|
|
1246
|
+
"outer_socket": OUTER_SOCKET,
|
|
1247
|
+
"inner_socket": INNER_SOCKET,
|
|
1248
|
+
"outer_session": OUTER_SESSION,
|
|
1249
|
+
"outer_window": OUTER_WINDOW,
|
|
1250
|
+
"inner_session": INNER_SESSION,
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
if not tmux_available():
|
|
1254
|
+
return state
|
|
1255
|
+
|
|
1256
|
+
try:
|
|
1257
|
+
state["outer_has_session"] = _cap(
|
|
1258
|
+
_run_outer_tmux(["has-session", "-t", OUTER_SESSION], capture=True, timeout_s=0.5)
|
|
1259
|
+
)
|
|
1260
|
+
state["outer_panes"] = _cap(
|
|
1261
|
+
_run_outer_tmux(
|
|
1262
|
+
[
|
|
1263
|
+
"list-panes",
|
|
1264
|
+
"-t",
|
|
1265
|
+
f"{OUTER_SESSION}:{OUTER_WINDOW}",
|
|
1266
|
+
"-F",
|
|
1267
|
+
"#{pane_index}\t#{pane_active}\t#{pane_current_command}\t#{pane_pid}\t#{pane_tty}",
|
|
1268
|
+
],
|
|
1269
|
+
capture=True,
|
|
1270
|
+
timeout_s=0.5,
|
|
1271
|
+
)
|
|
1272
|
+
)
|
|
1273
|
+
except Exception:
|
|
1274
|
+
pass
|
|
1275
|
+
|
|
1276
|
+
try:
|
|
1277
|
+
state["inner_has_session"] = _cap(
|
|
1278
|
+
_run_inner_tmux(["has-session", "-t", INNER_SESSION], capture=True, timeout_s=0.5)
|
|
1279
|
+
)
|
|
1280
|
+
state["inner_windows"] = _cap(
|
|
1281
|
+
_run_inner_tmux(
|
|
1282
|
+
[
|
|
1283
|
+
"list-windows",
|
|
1284
|
+
"-t",
|
|
1285
|
+
INNER_SESSION,
|
|
1286
|
+
"-F",
|
|
1287
|
+
"#{window_id}\t#{window_index}\t#{window_name}\t#{window_active}\t#{"
|
|
1288
|
+
+ OPT_TERMINAL_ID
|
|
1289
|
+
+ "}\t#{"
|
|
1290
|
+
+ OPT_PROVIDER
|
|
1291
|
+
+ "}\t#{"
|
|
1292
|
+
+ OPT_SESSION_TYPE
|
|
1293
|
+
+ "}\t#{"
|
|
1294
|
+
+ OPT_SESSION_ID
|
|
1295
|
+
+ "}\t#{"
|
|
1296
|
+
+ OPT_CONTEXT_ID
|
|
1297
|
+
+ "}\t#{"
|
|
1298
|
+
+ OPT_ATTENTION
|
|
1299
|
+
+ "}",
|
|
1300
|
+
],
|
|
1301
|
+
capture=True,
|
|
1302
|
+
timeout_s=0.5,
|
|
1303
|
+
)
|
|
1304
|
+
)
|
|
1305
|
+
except Exception:
|
|
1306
|
+
pass
|
|
1307
|
+
|
|
1308
|
+
return state
|