@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.
- package/README.md +6 -0
- package/npm/cli.cjs +29 -3
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +230 -79
- package/server/claude_goals.py +16 -4
- package/server/codex_app_server.py +4 -0
- package/server/codex_auth.py +31 -0
- package/server/codex_history_repair.py +92 -32
- package/server/codex_provider.py +12 -2
- package/server/execution_activation.py +1 -1
- package/server/execution_control.py +4 -0
- package/server/execution_install.py +48 -7
- package/server/execution_manage.py +304 -0
- package/server/execution_recovery.py +1 -1
- package/server/execution_uninstall.py +19 -0
- package/server/execution_update_status.py +3 -10
- package/server/opencode_agent_client.py +5 -0
- package/server/provider_connections.py +21 -11
- package/server/server_instances.py +211 -4
- package/server/update_handoff.py +6 -11
|
@@ -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
|
|
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
|
|
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
|
|
package/server/update_handoff.py
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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,
|