@agentsdock/server 1.0.10-beta.1 → 1.0.10-beta.6

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.
@@ -17,6 +17,7 @@ import os
17
17
  from pathlib import Path
18
18
  import plistlib
19
19
  import re
20
+ import select
20
21
  import shlex
21
22
  import shutil
22
23
  import socket
@@ -24,7 +25,9 @@ import stat
24
25
  import subprocess
25
26
  import sys
26
27
  import tempfile
28
+ import termios
27
29
  import time
30
+ import tty
28
31
  from urllib.parse import urlsplit
29
32
 
30
33
  INSTANCE_PROTOCOL = 1
@@ -135,6 +138,26 @@ def read_config(instance: Instance) -> dict[str, str]:
135
138
  return result
136
139
 
137
140
 
141
+ def split_layout(instance: Instance, platform: str = sys.platform):
142
+ """Default-only split detection; invalid evidence never falls back to legacy."""
143
+ if instance.name != "default":
144
+ return None
145
+ marker = instance.runtime / "execution-layout.json"
146
+ if not marker.exists() and not marker.is_symlink():
147
+ return None
148
+ import execution_install as files
149
+ from execution_uninstall import _service_files
150
+ layout = files.installed_layout(instance.runtime)
151
+ expected_platform = "Darwin" if platform == "darwin" else "Linux" if platform.startswith("linux") else None
152
+ if ((layout.install_root, layout.config_root, layout.state_root, layout.home, layout.platform)
153
+ != (instance.runtime, instance.config, instance.state, instance.home, expected_platform)):
154
+ raise ValueError("Default execution roots/platform do not match this instance.")
155
+ for role in ("worker", "gateway"):
156
+ check_path(layout.service_path(role), instance.home)
157
+ _service_files(layout)
158
+ return layout
159
+
160
+
138
161
  def validate_binding(instance: Instance, platform: str = sys.platform) -> None:
139
162
  for path in (instance.runtime, instance.config, instance.state, instance.logs, instance.service_file(platform)):
140
163
  check_path(path, instance.home)
@@ -142,6 +165,8 @@ def validate_binding(instance: Instance, platform: str = sys.platform) -> None:
142
165
  for key, value in instance.environment().items():
143
166
  if key in env and env[key] != value:
144
167
  raise ValueError(f"{instance.name}: {key} does not match this instance; refusing service changes.")
168
+ if split_layout(instance, platform) is not None:
169
+ return
145
170
  service = instance.service_file(platform)
146
171
  if service.exists():
147
172
  data = read_regular(service)
@@ -367,6 +392,22 @@ def run(command: list[str], **kwargs):
367
392
 
368
393
 
369
394
  def service_status(instance: Instance, platform: str = sys.platform) -> str:
395
+ try:
396
+ layout = split_layout(instance, platform)
397
+ if layout is not None:
398
+ from execution_manage import NativeServices, read_lifecycle_intent
399
+ native = NativeServices(layout)
400
+ native.validate_registered_bindings()
401
+ snapshot = native.snapshot()
402
+ states = {item["state"] for item in snapshot.values()}
403
+ intent = read_lifecycle_intent(layout)
404
+ if states <= {"absent", "stopped"}:
405
+ return "stopped"
406
+ if states == {"running"}:
407
+ return "pending" if intent is not None else "running"
408
+ return "partial"
409
+ except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
410
+ return "unknown"
370
411
  command = ["launchctl", "print", f"gui/{os.getuid()}/{launchd_label(instance.name)}"] if platform == "darwin" else ["systemctl", "--user", "show", service_name(instance.name) + ".service", "--property=ActiveState", "--value"]
371
412
  try:
372
413
  result = subprocess.run(command, capture_output=True, text=True, timeout=3, check=False)
@@ -611,6 +652,147 @@ def show(instance: Instance, *, network: dict[str, str] | None = None):
611
652
  print()
612
653
 
613
654
 
655
+ def show_status(instance: Instance, *, network: dict[str, str]) -> None:
656
+ """Read-only per-instance summary; never print configuration or tokens."""
657
+ item = describe(instance)
658
+ print(terminal_color(f"Server ({item['name']}):", "34"))
659
+ print(f" Status: {item['status']}")
660
+ addresses = []
661
+ if item["port"]:
662
+ choices = connection_choices(read_config(instance).get("AGENTSDOCK_AGENT_BIND", "0.0.0.0"),
663
+ item["port"], item["addresses"], network)
664
+ if choices["tailscale"]:
665
+ addresses.append(f"{choices['tailscale']} (Tailscale / other networks)")
666
+ for key, label in (("lan", "Same Wi-Fi / LAN"), ("local", "This machine only"),
667
+ ("other", "Other / unverified")):
668
+ addresses.extend(f"{url} ({label})" for url in choices[key])
669
+ print(" Address: " + (addresses[0] if addresses else "Not configured"))
670
+ for address in addresses[1:]:
671
+ print(" " + address)
672
+ print(f" Version: {item['version'] or 'unknown'}")
673
+ print(f" Port: {item['port'] or 'Not configured'}")
674
+ print()
675
+
676
+
677
+ def read_token_menu_key(fd: int) -> str:
678
+ key = os.read(fd, 1)
679
+ if key in (b"", b"\x03", b"\x04"):
680
+ return "cancel"
681
+ if key in (b"\r", b"\n"):
682
+ return "enter"
683
+ if key != b"\x1b":
684
+ return "other"
685
+ sequence = b""
686
+ while len(sequence) < 16 and select.select([fd], [], [], 0.15)[0]:
687
+ part = os.read(fd, 1)
688
+ if not part:
689
+ return "cancel"
690
+ sequence += part
691
+ if sequence in (b"[A", b"OA"):
692
+ return "up"
693
+ if sequence in (b"[B", b"OB"):
694
+ return "down"
695
+ if sequence[:1] not in (b"[", b"O") or (len(sequence) > 1 and 0x40 <= part[0] <= 0x7e):
696
+ return "other"
697
+ return "cancel" if not sequence else "other"
698
+
699
+
700
+ def arrow_token_choice(rows: list[str], fd: int, settings: list) -> int | None:
701
+ """Render a bounded terminal menu and restore input mode before token output."""
702
+ size = shutil.get_terminal_size(fallback=(80, 24))
703
+ visible = min(len(rows), max(1, size.lines - 6), 10)
704
+ width = max(1, size.columns - 1)
705
+ selected, start = 0, 0
706
+ hint = "Use ↑/↓ to choose; Enter to select; Esc to cancel."
707
+
708
+ def render(redraw=False):
709
+ if redraw:
710
+ sys.stdout.write(f"\x1b[{visible + 1}A")
711
+ for index in range(start, start + visible):
712
+ row = (" > " if index == selected else " ") + rows[index]
713
+ row = row[:width]
714
+ if index == selected:
715
+ row = terminal_color(row, "1;36")
716
+ sys.stdout.write("\r\x1b[2K" + row + "\n")
717
+ sys.stdout.write("\r\x1b[2K" + hint[:width] + "\n")
718
+ sys.stdout.flush()
719
+
720
+ try:
721
+ tty.setcbreak(fd, termios.TCSANOW)
722
+ sys.stdout.write("\x1b[?25l")
723
+ render()
724
+ while True:
725
+ key = read_token_menu_key(fd)
726
+ if key == "cancel":
727
+ return None
728
+ if key == "enter":
729
+ return selected
730
+ if key not in ("up", "down"):
731
+ continue
732
+ selected = (selected + (1 if key == "down" else -1)) % len(rows)
733
+ start = min(start, selected) if selected < start else max(start, selected - visible + 1)
734
+ render(redraw=True)
735
+ except KeyboardInterrupt:
736
+ return None
737
+ finally:
738
+ termios.tcsetattr(fd, termios.TCSANOW, settings)
739
+ sys.stdout.write("\x1b[?25h\n")
740
+ sys.stdout.flush()
741
+
742
+
743
+ def choose_token_instance(registry: Registry) -> Instance | None:
744
+ """Select by stable instance name; never default or expose unselected tokens."""
745
+ items = registry.instances()
746
+ if not items:
747
+ print("No installations found. Run agentsdock setup to create your first server.")
748
+ return None
749
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
750
+ raise ValueError("Choose a server in an interactive terminal, or run agentsdock token NAME "
751
+ "(for example: agentsdock token default). No token was shown.")
752
+ print("\nChoose a server to view its access token:\n")
753
+ rows = []
754
+ for index, instance in enumerate(items, 1):
755
+ config = read_config(instance)
756
+ port = config.get("AGENTSDOCK_AGENT_PORT") or "not configured"
757
+ rows.append(f"{instance.name:<20} {service_status(instance):<10} Port: {port}")
758
+ if os.environ.get("TERM", "dumb") not in ("", "dumb"):
759
+ try:
760
+ fd = sys.stdin.fileno()
761
+ settings = termios.tcgetattr(fd)
762
+ except (OSError, ValueError, termios.error):
763
+ pass # Basic terminals retain the explicit numbered/name prompt.
764
+ else:
765
+ index = arrow_token_choice(rows, fd, settings)
766
+ if index is None:
767
+ print("Cancelled; no token was shown.")
768
+ return None
769
+ selected = items[index]
770
+ if selected.name not in {item.name for item in registry.instances()}:
771
+ raise ValueError("That server is no longer available. Run agentsdock token again. No token was shown.")
772
+ return selected
773
+ for index, row in enumerate(rows, 1):
774
+ print(f" {index}. {row}")
775
+ print()
776
+ names = {item.name: item for item in items}
777
+ while True:
778
+ try:
779
+ answer = input("Enter a number or server name (Enter to cancel): ").strip()
780
+ except (EOFError, KeyboardInterrupt):
781
+ print()
782
+ answer = ""
783
+ if not answer:
784
+ print("Cancelled; no token was shown.")
785
+ return None
786
+ selected = names.get(answer)
787
+ if re.fullmatch(r"[1-9][0-9]{0,5}", answer) and int(answer) <= len(items):
788
+ selected = items[int(answer) - 1]
789
+ if selected is not None:
790
+ if selected.name not in {item.name for item in registry.instances()}:
791
+ raise ValueError("That server is no longer available. Run agentsdock token again. No token was shown.")
792
+ return selected
793
+ print(f"Please enter a number from 1 to {len(items)} or a listed server name (Enter cancels).")
794
+
795
+
614
796
  def terminal_color(text: str, code: str) -> str:
615
797
  color = sys.stdout.isatty() and os.environ.get("TERM") != "dumb" and "NO_COLOR" not in os.environ
616
798
  return f"\033[{code}m{text}\033[0m" if color else text
@@ -719,6 +901,11 @@ def release_uninstalled_name(instance: Instance, registry: Registry) -> None:
719
901
 
720
902
  def control(instance: Instance, action: str, platform: str = sys.platform):
721
903
  validate_binding(instance, platform)
904
+ layout = split_layout(instance, platform)
905
+ if layout is not None:
906
+ from execution_manage import LifecycleController
907
+ LifecycleController(layout).run(action)
908
+ return
722
909
  if not instance.service_file(platform).exists():
723
910
  raise ValueError(f"{instance.name}: no installed service.")
724
911
  if platform == "darwin":
@@ -747,8 +934,11 @@ def parser() -> argparse.ArgumentParser:
747
934
  result = argparse.ArgumentParser(description="Independent AgentsServer instances for the current OS user. Defaults to list.")
748
935
  commands = result.add_subparsers(dest="command")
749
936
  commands.add_parser("list")
937
+ commands.add_parser("token", help="Choose an existing server and display only its access token")
750
938
  info = commands.add_parser("info")
751
939
  info.add_argument("name", type=instance_name)
940
+ status = commands.add_parser("status", help="Show readable details for all instances or one name")
941
+ status.add_argument("name", nargs="?", type=instance_name)
752
942
  new = commands.add_parser("new")
753
943
  new.add_argument("--name", type=instance_name)
754
944
  new.add_argument("--port", type=int)
@@ -785,13 +975,30 @@ def main(argv: list[str] | None = None) -> int:
785
975
  validate_binding(instance)
786
976
  print(instance.shell_bindings())
787
977
  return 0
788
- if args.command in {None, "list", "info"}:
978
+ if args.command == "token":
979
+ selected = choose_token_instance(registry)
980
+ if selected is None:
981
+ return 0
982
+ try:
983
+ run(["/bin/bash", str(ROOT / "install.sh"), "--instance", selected.name, "--show-token"],
984
+ env=clean_environment(selected), cwd=ROOT)
985
+ return 0
986
+ except subprocess.CalledProcessError as exc:
987
+ return exc.returncode if exc.returncode > 0 else 1
988
+ if args.command in {None, "list", "info", "status"}:
789
989
  items = registry.instances()
790
- if args.command == "info":
990
+ if args.command in {"info", "status"} and args.name:
791
991
  items = [item for item in items if item.name == args.name]
792
992
  if not items:
793
993
  raise ValueError("Unknown instance.")
994
+ if args.command == "info":
794
995
  print(json.dumps(describe(items[0]), indent=2))
996
+ elif args.command == "status":
997
+ network = tailscale_status() if items else None
998
+ for item in items:
999
+ show_status(item, network=network)
1000
+ if not items:
1001
+ print("No installations found. Run agentsdock setup to create your first server.")
795
1002
  else:
796
1003
  network = tailscale_status() if items else None
797
1004
  print("NAME STATUS PORT")
@@ -916,13 +1123,13 @@ def main(argv: list[str] | None = None) -> int:
916
1123
  control(item, args.command)
917
1124
  if args.command != "remove":
918
1125
  print(f"{item.name}: {args.command} completed", flush=True)
919
- except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as exc:
1126
+ except (OSError, ValueError, RuntimeError, KeyError, subprocess.CalledProcessError) as exc:
920
1127
  print(f"{item.name}: failed ({exc})", file=sys.stderr)
921
1128
  failures += 1
922
1129
  if args.command == "remove" and not failures:
923
1130
  print("\n" + terminal_color("Successful!", "32"), flush=True)
924
1131
  return int(bool(failures))
925
- except (OSError, ValueError, KeyError) as exc:
1132
+ except (OSError, ValueError, RuntimeError, KeyError) as exc:
926
1133
  print(f"Instance manager: {exc}", file=sys.stderr)
927
1134
  return 1
928
1135
 
@@ -30,9 +30,11 @@ def _no_activation(root: Path) -> None:
30
30
 
31
31
  def _no_handoff_recovery_overlap(root: Path) -> None:
32
32
  _no_activation(root)
33
- marker = root / ".execution-uninstall.json"
34
- if marker.exists() or marker.is_symlink():
35
- raise RuntimeError("uninstall already owns execution recovery")
33
+ for name in (".execution-uninstall.json", ".execution-lifecycle.json"):
34
+ marker = root / name
35
+ if marker.exists() or marker.is_symlink():
36
+ owner = "uninstall" if name == ".execution-uninstall.json" else "native lifecycle"
37
+ raise RuntimeError(owner + " already owns execution recovery")
36
38
 
37
39
 
38
40
  def _read_handoff(root: Path, path: Path) -> dict[str, Any]:
@@ -68,14 +70,7 @@ def _read_handoff(root: Path, path: Path) -> dict[str, Any]:
68
70
 
69
71
 
70
72
  def _installed_layout(root: Path) -> files.ExecutionLayout:
71
- data, _mode = files._read_file(root / files.LAYOUT_NAME, private=True)
72
- value = json.loads(data)
73
- if not isinstance(value, dict):
74
- raise ValueError("installed execution layout is invalid")
75
- layout = files.ExecutionLayout.from_dict(value.get("layout"))
76
- if layout.install_root != root or value != files.layout_manifest(layout):
77
- raise ValueError("installed execution layout changed")
78
- return layout
73
+ return files.installed_layout(root)
79
74
 
80
75
 
81
76
  def _matching_status(handoff: dict, layout: files.ExecutionLayout, record: dict,