aline-ai 0.7.3__py3-none-any.whl → 0.7.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.
@@ -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,11 @@ 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
+ _STATE_BORDER_RESIZE_ENABLED_KEY = "tmux_border_resize_enabled"
55
+
50
56
 
51
57
  @dataclass(frozen=True)
52
58
  class InnerWindow:
@@ -62,6 +68,9 @@ class InnerWindow:
62
68
  attention: str | None = None # "permission_request", "stop", or None
63
69
  created_at: float | None = None # Unix timestamp when window was created
64
70
  no_track: bool = False # Whether tracking is disabled for this terminal
71
+ pane_pid: int | None = None # PID of the initial process in the pane
72
+ pane_current_command: str | None = None # Foreground process in the pane
73
+ pane_tty: str | None = None # Controlling TTY for processes in the pane
65
74
 
66
75
 
67
76
  def tmux_available() -> bool:
@@ -93,26 +102,31 @@ def tmux_version() -> tuple[int, int] | None:
93
102
  return int(match.group(1)), int(match.group(2))
94
103
 
95
104
 
96
- def _run_tmux(args: Sequence[str], *, capture: bool = False) -> subprocess.CompletedProcess[str]:
97
- return subprocess.run(
98
- ["tmux", *args],
99
- text=True,
100
- capture_output=capture,
101
- check=False,
102
- )
105
+ def _run_tmux(
106
+ args: Sequence[str], *, capture: bool = False, timeout_s: float | None = None
107
+ ) -> subprocess.CompletedProcess[str]:
108
+ kwargs: dict[str, object] = {
109
+ "text": True,
110
+ "capture_output": capture,
111
+ "check": False,
112
+ }
113
+ # Keep keyword list minimal for test fakes that don't accept extra kwargs.
114
+ if timeout_s is not None:
115
+ kwargs["timeout"] = timeout_s
116
+ return subprocess.run(["tmux", *args], **kwargs) # type: ignore[arg-type]
103
117
 
104
118
 
105
119
  def _run_outer_tmux(
106
- args: Sequence[str], *, capture: bool = False
120
+ args: Sequence[str], *, capture: bool = False, timeout_s: float | None = None
107
121
  ) -> subprocess.CompletedProcess[str]:
108
122
  """Run tmux commands against the dedicated outer server socket."""
109
- return _run_tmux(["-L", OUTER_SOCKET, *args], capture=capture)
123
+ return _run_tmux(["-L", OUTER_SOCKET, *args], capture=capture, timeout_s=timeout_s)
110
124
 
111
125
 
112
126
  def _run_inner_tmux(
113
- args: Sequence[str], *, capture: bool = False
127
+ args: Sequence[str], *, capture: bool = False, timeout_s: float | None = None
114
128
  ) -> subprocess.CompletedProcess[str]:
115
- return _run_tmux(["-L", INNER_SOCKET, *args], capture=capture)
129
+ return _run_tmux(["-L", INNER_SOCKET, *args], capture=capture, timeout_s=timeout_s)
116
130
 
117
131
 
118
132
  def _python_dashboard_command() -> str:
@@ -256,6 +270,12 @@ def _load_terminal_state_from_json() -> dict[str, dict[str, str]]:
256
270
  return {}
257
271
 
258
272
 
273
+ _TERMINAL_STATE_CACHE_LOCK = threading.Lock()
274
+ _TERMINAL_STATE_CACHE: dict[str, dict[str, str]] | None = None
275
+ _TERMINAL_STATE_CACHE_AT: float = 0.0
276
+ _TERMINAL_STATE_CACHE_TTL_S: float = 1.5
277
+
278
+
259
279
  def _load_terminal_state() -> dict[str, dict[str, str]]:
260
280
  """Load terminal state.
261
281
 
@@ -265,6 +285,13 @@ def _load_terminal_state() -> dict[str, dict[str, str]]:
265
285
 
266
286
  Merges both sources, with DB taking precedence.
267
287
  """
288
+ global _TERMINAL_STATE_CACHE, _TERMINAL_STATE_CACHE_AT
289
+ now = time.monotonic()
290
+ with _TERMINAL_STATE_CACHE_LOCK:
291
+ cache = _TERMINAL_STATE_CACHE
292
+ if cache is not None and (now - _TERMINAL_STATE_CACHE_AT) <= _TERMINAL_STATE_CACHE_TTL_S:
293
+ return dict(cache)
294
+
268
295
  # Phase 1: Load from database
269
296
  db_state = _load_terminal_state_from_db()
270
297
 
@@ -275,6 +302,10 @@ def _load_terminal_state() -> dict[str, dict[str, str]]:
275
302
  result = dict(json_state)
276
303
  result.update(db_state)
277
304
 
305
+ with _TERMINAL_STATE_CACHE_LOCK:
306
+ _TERMINAL_STATE_CACHE = dict(result)
307
+ _TERMINAL_STATE_CACHE_AT = time.monotonic()
308
+
278
309
  return result
279
310
 
280
311
 
@@ -471,6 +502,13 @@ def bootstrap_dashboard_into_tmux() -> None:
471
502
 
472
503
  # Enable mouse for the managed session only.
473
504
  _run_outer_tmux(["set-option", "-t", OUTER_SESSION, "mouse", "on"])
505
+ _disable_outer_border_resize()
506
+ try:
507
+ from .state import set_dashboard_state_value
508
+
509
+ set_dashboard_state_value(_STATE_BORDER_RESIZE_ENABLED_KEY, False)
510
+ except Exception:
511
+ pass
474
512
 
475
513
  # Disable status bar for cleaner UI (Aline sessions only).
476
514
  _run_outer_tmux(["set-option", "-t", OUTER_SESSION, "status", "off"])
@@ -506,6 +544,9 @@ def bootstrap_dashboard_into_tmux() -> None:
506
544
  )
507
545
  _run_outer_tmux(["select-window", "-t", f"{OUTER_SESSION}:{OUTER_WINDOW}"])
508
546
 
547
+ # Best-effort: enforce a stable dashboard pane width if the terminal pane already exists.
548
+ enforce_outer_dashboard_pane_width(DEFAULT_DASHBOARD_PANE_WIDTH_COLS)
549
+
509
550
  # Sanity-check before exec'ing into tmux attach. If this fails, fall back to non-tmux mode.
510
551
  ready = _run_outer_tmux(["has-session", "-t", OUTER_SESSION], capture=True)
511
552
  if ready.returncode != 0:
@@ -691,6 +732,7 @@ def ensure_right_pane(width_percent: int = 50) -> bool:
691
732
  )
692
733
  panes = _parse_lines(panes_out)
693
734
  if len(panes) >= 2:
735
+ enforce_outer_dashboard_pane_width(DEFAULT_DASHBOARD_PANE_WIDTH_COLS)
694
736
  return True
695
737
 
696
738
  # Split from the dashboard pane to keep it on the left.
@@ -705,9 +747,157 @@ def ensure_right_pane(width_percent: int = 50) -> bool:
705
747
  f"{OUTER_SESSION}:{OUTER_WINDOW}.0",
706
748
  "-d",
707
749
  attach_cmd,
708
- ]
750
+ ],
751
+ capture=True,
709
752
  )
710
- return split.returncode == 0
753
+ if split.returncode != 0:
754
+ detail = (split.stderr or split.stdout or "").strip()
755
+ if detail:
756
+ logger.warning(f"ensure_right_pane split-window failed: {detail}")
757
+ if split.returncode == 0:
758
+ enforce_outer_dashboard_pane_width(DEFAULT_DASHBOARD_PANE_WIDTH_COLS)
759
+ return True
760
+ return False
761
+
762
+
763
+ def ensure_right_pane_ready(width_percent: int = 50) -> bool:
764
+ """Ensure the right pane exists and is attached to the inner tmux session."""
765
+ try:
766
+ ok = ensure_right_pane(width_percent)
767
+ except TypeError:
768
+ # Tests and some callers monkeypatch ensure_right_pane() as a no-arg lambda.
769
+ ok = ensure_right_pane()
770
+ if not ok:
771
+ return False
772
+
773
+ # Best-effort: enforce a stable dashboard pane width whenever we touch the outer layout
774
+ # (unless user enabled drag-to-resize).
775
+ enforce_outer_dashboard_pane_width(DEFAULT_DASHBOARD_PANE_WIDTH_COLS)
776
+
777
+ attach_cmd = shlex.join(["tmux", "-L", INNER_SOCKET, "attach", "-t", INNER_SESSION])
778
+
779
+ # If the right pane exists but isn't running `tmux attach`, it may look "blank" or stale.
780
+ try:
781
+ proc = _run_outer_tmux(
782
+ [
783
+ "display-message",
784
+ "-p",
785
+ "-t",
786
+ f"{OUTER_SESSION}:{OUTER_WINDOW}.1",
787
+ "#{pane_current_command}",
788
+ ],
789
+ capture=True,
790
+ )
791
+ current_cmd = (proc.stdout or "").strip()
792
+ except Exception:
793
+ current_cmd = ""
794
+
795
+ if current_cmd and current_cmd != "tmux":
796
+ respawn = _run_outer_tmux(
797
+ [
798
+ "respawn-pane",
799
+ "-k",
800
+ "-t",
801
+ f"{OUTER_SESSION}:{OUTER_WINDOW}.1",
802
+ attach_cmd,
803
+ ],
804
+ capture=True,
805
+ )
806
+ if respawn.returncode != 0:
807
+ detail = (respawn.stderr or respawn.stdout or "").strip()
808
+ if detail:
809
+ logger.warning(f"ensure_right_pane_ready respawn failed: {detail}")
810
+ return False
811
+
812
+ return True
813
+
814
+
815
+ def _disable_outer_border_resize() -> None:
816
+ """Disable mouse drag-to-resize on pane borders (outer dashboard tmux server only)."""
817
+ try:
818
+ # tmux enables border resizing via MouseDrag1Border. Unbind it on our dedicated server
819
+ # to avoid accidental resizing when selecting text near the pane divider.
820
+ for key in ("MouseDrag1Border", "MouseDown1Border", "MouseDragEnd1Border", "MouseUp1Border"):
821
+ _run_outer_tmux(["unbind-key", "-T", "root", key], capture=True)
822
+ except Exception:
823
+ return
824
+
825
+
826
+ def outer_border_resize_enabled() -> bool:
827
+ """Return True when user has enabled tmux border drag-to-resize in the dashboard."""
828
+ try:
829
+ from .state import get_dashboard_state_value
830
+
831
+ return bool(get_dashboard_state_value(_STATE_BORDER_RESIZE_ENABLED_KEY, False))
832
+ except Exception:
833
+ return False
834
+
835
+
836
+ def _set_outer_dashboard_pane_width(width_cols: int) -> None:
837
+ """Best-effort: size the left dashboard pane to a fixed width (in terminal columns)."""
838
+ try:
839
+ w = int(width_cols)
840
+ if w <= 0:
841
+ return
842
+ except Exception:
843
+ return
844
+
845
+ try:
846
+ # Avoid assuming pane indexes are 0/1. Determine the leftmost pane by geometry.
847
+ panes_out = (
848
+ _run_outer_tmux(
849
+ [
850
+ "list-panes",
851
+ "-t",
852
+ f"{OUTER_SESSION}:{OUTER_WINDOW}",
853
+ "-F",
854
+ "#{pane_id}\t#{pane_left}\t#{pane_index}",
855
+ ],
856
+ capture=True,
857
+ timeout_s=0.2,
858
+ ).stdout
859
+ or ""
860
+ )
861
+ panes: list[tuple[int, int, str]] = []
862
+ for line in _parse_lines(panes_out):
863
+ parts = line.split("\t")
864
+ if len(parts) < 2:
865
+ continue
866
+ pane_id = (parts[0] or "").strip()
867
+ if not pane_id:
868
+ continue
869
+ try:
870
+ pane_left = int((parts[1] or "0").strip())
871
+ except Exception:
872
+ continue
873
+ pane_index = 0
874
+ if len(parts) > 2 and (parts[2] or "").strip():
875
+ try:
876
+ pane_index = int(parts[2].strip())
877
+ except Exception:
878
+ pane_index = 0
879
+ panes.append((pane_left, pane_index, pane_id))
880
+
881
+ # Only enforce widths when there are two panes (dashboard + terminal).
882
+ if len(panes) < 2:
883
+ return
884
+
885
+ panes.sort(key=lambda t: (t[0], t[1]))
886
+ leftmost_pane_id = panes[0][2]
887
+ _run_outer_tmux(
888
+ ["resize-pane", "-t", leftmost_pane_id, "-x", str(w)],
889
+ capture=True,
890
+ timeout_s=0.2,
891
+ )
892
+ except Exception:
893
+ return
894
+
895
+
896
+ def enforce_outer_dashboard_pane_width(width_cols: int = DEFAULT_DASHBOARD_PANE_WIDTH_COLS) -> None:
897
+ """Best-effort: enforce the fixed dashboard pane width for the outer tmux layout."""
898
+ if outer_border_resize_enabled():
899
+ return
900
+ _set_outer_dashboard_pane_width(width_cols)
711
901
 
712
902
 
713
903
  def list_inner_windows() -> list[InnerWindow]:
@@ -745,7 +935,7 @@ def list_inner_windows() -> list[InnerWindow]:
745
935
  + OPT_CREATED_AT
746
936
  + "}\t#{"
747
937
  + OPT_NO_TRACK
748
- + "}",
938
+ + "}\t#{pane_pid}\t#{pane_current_command}\t#{pane_tty}",
749
939
  ],
750
940
  capture=True,
751
941
  ).stdout
@@ -775,6 +965,15 @@ def list_inner_windows() -> list[InnerWindow]:
775
965
  pass
776
966
  no_track_str = parts[11] if len(parts) > 11 and parts[11] else None
777
967
  no_track = no_track_str == "1"
968
+ pane_pid_str = parts[12] if len(parts) > 12 and parts[12] else None
969
+ pane_pid: int | None = None
970
+ if pane_pid_str:
971
+ try:
972
+ pane_pid = int(pane_pid_str)
973
+ except ValueError:
974
+ pass
975
+ pane_current_command = parts[13] if len(parts) > 13 and parts[13] else None
976
+ pane_tty = parts[14] if len(parts) > 14 and parts[14] else None
778
977
 
779
978
  if terminal_id:
780
979
  persisted = state.get(terminal_id) or {}
@@ -833,6 +1032,9 @@ def list_inner_windows() -> list[InnerWindow]:
833
1032
  attention=attention,
834
1033
  created_at=created_at,
835
1034
  no_track=no_track,
1035
+ pane_pid=pane_pid,
1036
+ pane_current_command=pane_current_command,
1037
+ pane_tty=pane_tty,
836
1038
  )
837
1039
  )
838
1040
  # Sort by creation time (newest first). Windows without created_at go to the bottom.
@@ -840,6 +1042,33 @@ def list_inner_windows() -> list[InnerWindow]:
840
1042
  return windows
841
1043
 
842
1044
 
1045
+ def list_outer_panes(*, timeout_s: float = 0.2) -> list[str]:
1046
+ """List panes in the outer dashboard window (best-effort).
1047
+
1048
+ Intended for lightweight watchdog checks; returns tab-delimited lines in the same
1049
+ format as `collect_tmux_debug_state()["outer_panes"]["stdout"]`.
1050
+ """
1051
+ if not tmux_available():
1052
+ return []
1053
+ try:
1054
+ proc = _run_outer_tmux(
1055
+ [
1056
+ "list-panes",
1057
+ "-t",
1058
+ f"{OUTER_SESSION}:{OUTER_WINDOW}",
1059
+ "-F",
1060
+ "#{pane_index}\t#{pane_active}\t#{pane_current_command}\t#{pane_pid}\t#{pane_tty}",
1061
+ ],
1062
+ capture=True,
1063
+ timeout_s=timeout_s,
1064
+ )
1065
+ except Exception:
1066
+ return []
1067
+ if proc.returncode != 0:
1068
+ return []
1069
+ return [ln for ln in (proc.stdout or "").splitlines() if ln.strip()]
1070
+
1071
+
843
1072
  def set_inner_window_options(window_id: str, options: dict[str, str]) -> bool:
844
1073
  import time as _time
845
1074
 
@@ -874,7 +1103,8 @@ def create_inner_window(
874
1103
 
875
1104
  t0 = _time.time()
876
1105
  logger.debug("[PERF] create_inner_window START")
877
- if not ensure_right_pane():
1106
+ if not ensure_right_pane_ready():
1107
+ logger.warning("create_inner_window: right pane unavailable")
878
1108
  return None
879
1109
  logger.debug(f"[PERF] create_inner_window ensure_right_pane: {_time.time() - t0:.3f}s")
880
1110
 
@@ -903,6 +1133,9 @@ def create_inner_window(
903
1133
  )
904
1134
  logger.debug(f"[PERF] create_inner_window new-window: {_time.time() - t2:.3f}s")
905
1135
  if proc.returncode != 0:
1136
+ detail = (proc.stderr or proc.stdout or "").strip()
1137
+ if detail:
1138
+ logger.warning(f"create_inner_window new-window failed: {detail}")
906
1139
  return None
907
1140
 
908
1141
  created = _parse_lines(proc.stdout or "")
@@ -943,16 +1176,26 @@ def create_inner_window(
943
1176
 
944
1177
 
945
1178
  def select_inner_window(window_id: str) -> bool:
946
- if not ensure_right_pane():
1179
+ if not ensure_right_pane_ready():
947
1180
  return False
948
- return _run_inner_tmux(["select-window", "-t", window_id]).returncode == 0
1181
+ proc = _run_inner_tmux(["select-window", "-t", window_id], capture=True)
1182
+ if proc.returncode != 0:
1183
+ detail = (proc.stderr or proc.stdout or "").strip()
1184
+ if detail:
1185
+ logger.warning(f"select_inner_window failed ({window_id}): {detail}")
1186
+ return False
1187
+ return True
949
1188
 
950
1189
 
951
1190
  def focus_right_pane() -> bool:
952
1191
  """Focus the right pane (terminal area) in the outer tmux layout."""
953
- return (
954
- _run_outer_tmux(["select-pane", "-t", f"{OUTER_SESSION}:{OUTER_WINDOW}.1"]).returncode == 0
955
- )
1192
+ proc = _run_outer_tmux(["select-pane", "-t", f"{OUTER_SESSION}:{OUTER_WINDOW}.1"], capture=True)
1193
+ if proc.returncode != 0:
1194
+ detail = (proc.stderr or proc.stdout or "").strip()
1195
+ if detail:
1196
+ logger.warning(f"focus_right_pane failed: {detail}")
1197
+ return False
1198
+ return True
956
1199
 
957
1200
 
958
1201
  def clear_attention(window_id: str) -> bool:
@@ -992,3 +1235,94 @@ def get_active_context_id(*, allowed_providers: set[str] | None = None) -> str |
992
1235
 
993
1236
  context_id = (active.context_id or "").strip()
994
1237
  return context_id or None
1238
+
1239
+
1240
+ def collect_tmux_debug_state() -> dict[str, object]:
1241
+ """Best-effort snapshot of tmux state for diagnosing blank/stuck panes.
1242
+
1243
+ This must be non-intrusive: it should not create sessions or change state.
1244
+ """
1245
+
1246
+ def _cap(proc: subprocess.CompletedProcess[str] | None) -> dict[str, object]:
1247
+ if proc is None:
1248
+ return {}
1249
+
1250
+ def _trim(s: str | None) -> str:
1251
+ text = (s or "").strip()
1252
+ if len(text) > 4000:
1253
+ return text[:4000] + "…(truncated)"
1254
+ return text
1255
+
1256
+ return {
1257
+ "rc": int(getattr(proc, "returncode", -1)),
1258
+ "stdout": _trim(getattr(proc, "stdout", "")),
1259
+ "stderr": _trim(getattr(proc, "stderr", "")),
1260
+ }
1261
+
1262
+ state: dict[str, object] = {
1263
+ "tmux_available": tmux_available(),
1264
+ "in_tmux": in_tmux(),
1265
+ "managed_env": managed_env_enabled(),
1266
+ "outer_socket": OUTER_SOCKET,
1267
+ "inner_socket": INNER_SOCKET,
1268
+ "outer_session": OUTER_SESSION,
1269
+ "outer_window": OUTER_WINDOW,
1270
+ "inner_session": INNER_SESSION,
1271
+ }
1272
+
1273
+ if not tmux_available():
1274
+ return state
1275
+
1276
+ try:
1277
+ state["outer_has_session"] = _cap(
1278
+ _run_outer_tmux(["has-session", "-t", OUTER_SESSION], capture=True, timeout_s=0.5)
1279
+ )
1280
+ state["outer_panes"] = _cap(
1281
+ _run_outer_tmux(
1282
+ [
1283
+ "list-panes",
1284
+ "-t",
1285
+ f"{OUTER_SESSION}:{OUTER_WINDOW}",
1286
+ "-F",
1287
+ "#{pane_index}\t#{pane_active}\t#{pane_current_command}\t#{pane_pid}\t#{pane_tty}",
1288
+ ],
1289
+ capture=True,
1290
+ timeout_s=0.5,
1291
+ )
1292
+ )
1293
+ except Exception:
1294
+ pass
1295
+
1296
+ try:
1297
+ state["inner_has_session"] = _cap(
1298
+ _run_inner_tmux(["has-session", "-t", INNER_SESSION], capture=True, timeout_s=0.5)
1299
+ )
1300
+ state["inner_windows"] = _cap(
1301
+ _run_inner_tmux(
1302
+ [
1303
+ "list-windows",
1304
+ "-t",
1305
+ INNER_SESSION,
1306
+ "-F",
1307
+ "#{window_id}\t#{window_index}\t#{window_name}\t#{window_active}\t#{"
1308
+ + OPT_TERMINAL_ID
1309
+ + "}\t#{"
1310
+ + OPT_PROVIDER
1311
+ + "}\t#{"
1312
+ + OPT_SESSION_TYPE
1313
+ + "}\t#{"
1314
+ + OPT_SESSION_ID
1315
+ + "}\t#{"
1316
+ + OPT_CONTEXT_ID
1317
+ + "}\t#{"
1318
+ + OPT_ATTENTION
1319
+ + "}",
1320
+ ],
1321
+ capture=True,
1322
+ timeout_s=0.5,
1323
+ )
1324
+ )
1325
+ except Exception:
1326
+ pass
1327
+
1328
+ return state