@agentsdock/server 1.0.9 → 1.0.10-beta.4

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.
@@ -0,0 +1,229 @@
1
+ """Keep nonstandard custom Responses errors visible to native Codex.
2
+
3
+ An owned loopback transport forwards requests unchanged to one immutable endpoint.
4
+ Only untyped SSE error packets are normalized; native Codex owns all retries.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ from contextlib import contextmanager, suppress
10
+ import hmac
11
+ import json
12
+ import secrets
13
+ import socket
14
+ from urllib.parse import quote
15
+
16
+ import httpx
17
+ import uvicorn
18
+ from fastapi import FastAPI, Request
19
+ from fastapi.responses import Response, StreamingResponse
20
+
21
+ from codex_app_server import CodexAppServerManager
22
+ from codex_provider import ENV_KEY, PROVIDER_ID, config_args
23
+
24
+ # Larger frames pass through unchanged, rather than buffering arbitrary output.
25
+ # This is a parser memory bound, not a response or content size limit.
26
+ _ERROR_FRAME_BYTES = 64 * 1024
27
+ _HOP_HEADERS = {'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
28
+ 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length'}
29
+
30
+
31
+ class ErrorFrames:
32
+ def __init__(self, secret: str):
33
+ self.secret = secret
34
+ self.pending = bytearray()
35
+ self.passthrough = False
36
+ self.response_id = 'resp_custom_endpoint_error'
37
+
38
+ def frame(self, raw: bytes) -> bytes:
39
+ try:
40
+ data = b'\n'.join(line[5:].lstrip(b' ') for line in raw.splitlines() if line.startswith(b'data:'))
41
+ packet = json.loads(data)
42
+ except (ValueError, UnicodeError):
43
+ return raw
44
+ if not isinstance(packet, dict):
45
+ return raw
46
+ response = packet.get('response')
47
+ if isinstance(response, dict) and isinstance(response.get('id'), str):
48
+ self.response_id = response['id']
49
+ error = packet.get('error')
50
+ if packet.get('type') or not error:
51
+ return raw
52
+ if isinstance(error, dict):
53
+ message = error.get('message')
54
+ if not isinstance(message, str) or not message:
55
+ return raw
56
+ code = str(error.get('code') or error.get('type') or 'server_error')
57
+ elif isinstance(error, str):
58
+ message, code = error, 'server_error'
59
+ else:
60
+ return raw
61
+ code = {'429': 'rate_limit_exceeded', '401': 'invalid_api_key',
62
+ '403': 'permission_denied', '400': 'invalid_request_error',
63
+ '500': 'server_error', '502': 'server_error', '503': 'server_error'}.get(code, code)
64
+ for value in (self.secret, quote(self.secret, safe='')):
65
+ if value:
66
+ message = message.replace(value, '[redacted]')
67
+ failed = {'type': 'response.failed', 'response': {
68
+ 'id': self.response_id, 'object': 'response', 'created_at': 0,
69
+ 'status': 'failed', 'output': [], 'error': {'code': code, 'message': message}}}
70
+ return b'event: response.failed\ndata: ' + json.dumps(failed, ensure_ascii=False).encode() + b'\n\n'
71
+
72
+ async def stream(self, chunks):
73
+ # Split at SSE blank lines, including CRLF boundaries across chunks.
74
+ async for chunk in chunks:
75
+ self.pending.extend(chunk)
76
+ while self.pending:
77
+ lf = self.pending.find(b'\n\n')
78
+ crlf = self.pending.find(b'\r\n\r\n')
79
+ endings = [(i, n) for i, n in ((lf, 2), (crlf, 4)) if i >= 0]
80
+ if endings:
81
+ i, n = min(endings)
82
+ raw = bytes(self.pending[:i+n]); del self.pending[:i+n]
83
+ yield raw if self.passthrough or len(raw) > _ERROR_FRAME_BYTES else self.frame(raw)
84
+ self.passthrough = False
85
+ elif len(self.pending) > _ERROR_FRAME_BYTES:
86
+ # Retain only a delimiter tail; do not truncate a large frame.
87
+ yield bytes(self.pending[:-3]); del self.pending[:-3]
88
+ self.passthrough = True
89
+ else:
90
+ break
91
+ if self.pending:
92
+ raw = bytes(self.pending); self.pending.clear()
93
+ yield raw if self.passthrough else self.frame(raw)
94
+
95
+
96
+ class _OwnedConfig(uvicorn.Config):
97
+ def configure_logging(self):
98
+ # Do not change the host server's process-wide logging configuration.
99
+ pass
100
+
101
+
102
+ class _OwnedServer(uvicorn.Server):
103
+ @contextmanager
104
+ def capture_signals(self):
105
+ # This transport must never take over its host server's signal handlers.
106
+ yield
107
+
108
+
109
+ class ResponseStreamBridge:
110
+ def __init__(self, selected: dict):
111
+ self.upstream = selected['base_url'].rstrip('/')
112
+ self.key = selected['api_key']
113
+ self.token = secrets.token_urlsafe(32)
114
+ self.url = ''
115
+ self.task = self.server = self.client = self.socket = None
116
+ app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
117
+ app.add_api_route('/v1/{path:path}', self.forward, methods=['POST', 'GET', 'DELETE'])
118
+ self.app = app
119
+
120
+ async def start(self):
121
+ if self.task is not None and not self.task.done():
122
+ return
123
+ self.client = httpx.AsyncClient(timeout=None, follow_redirects=False, trust_env=False)
124
+ self.socket = socket.socket()
125
+ self.socket.bind(('127.0.0.1', 0)); self.socket.listen(128); self.socket.setblocking(False)
126
+ self.url = f'http://127.0.0.1:{self.socket.getsockname()[1]}/v1'
127
+ self.server = _OwnedServer(_OwnedConfig(self.app, log_config=None, access_log=False, lifespan='off', log_level='critical'))
128
+ self.task = asyncio.create_task(self.server.serve(sockets=[self.socket]))
129
+ try:
130
+ while not self.server.started:
131
+ if self.task.done():
132
+ await self.task
133
+ raise RuntimeError('Custom response transport could not start')
134
+ await asyncio.sleep(0)
135
+ except BaseException:
136
+ await self.close()
137
+ raise
138
+
139
+ async def forward(self, request: Request, path: str):
140
+ if not hmac.compare_digest(request.headers.get('authorization', ''), 'Bearer ' + self.token):
141
+ return Response(status_code=401)
142
+ # The destination is immutable. A request cannot name a different origin.
143
+ if not (path == 'responses' or path.startswith('responses/')) or '..' in path.split('/'):
144
+ return Response(status_code=404)
145
+ headers = {k: v for k, v in request.headers.items() if k.lower() not in _HOP_HEADERS | {'authorization', 'accept-encoding', 'cookie'}}
146
+ headers.update(authorization='Bearer ' + self.key, **{'accept-encoding': 'identity'})
147
+ if 'content-length' in request.headers:
148
+ headers['content-length'] = request.headers['content-length']
149
+ uploaded = asyncio.Event()
150
+ async def upload():
151
+ try:
152
+ async for chunk in request.stream(): yield chunk
153
+ finally:
154
+ uploaded.set()
155
+ async def disconnected():
156
+ # Wait for the upload reader to finish before reading disconnects.
157
+ # No polling, execution deadline, or competing request-body reader.
158
+ await uploaded.wait()
159
+ while (await request.receive()).get('type') != 'http.disconnect':
160
+ pass
161
+ req = self.client.build_request(request.method, self.upstream + '/' + path,
162
+ params=request.query_params.multi_items(), headers=headers, content=upload())
163
+ sending = asyncio.create_task(self.client.send(req, stream=True))
164
+ disconnect = asyncio.create_task(disconnected())
165
+ try:
166
+ done, _ = await asyncio.wait((sending, disconnect), return_when=asyncio.FIRST_COMPLETED)
167
+ if disconnect in done:
168
+ if sending.done() and not sending.cancelled() and sending.exception() is None:
169
+ await sending.result().aclose()
170
+ return Response(status_code=499)
171
+ upstream = sending.result()
172
+ except httpx.HTTPError:
173
+ return Response('Custom endpoint connection failed', status_code=502)
174
+ finally:
175
+ for task in (sending, disconnect):
176
+ if not task.done(): task.cancel()
177
+ await asyncio.gather(sending, disconnect, return_exceptions=True)
178
+ headers = {k: v for k, v in upstream.headers.items() if k.lower() not in _HOP_HEADERS | {'content-encoding', 'set-cookie'}}
179
+ async def body():
180
+ try:
181
+ chunks = upstream.aiter_bytes()
182
+ if upstream.status_code == 200 and 'text/event-stream' in upstream.headers.get('content-type', '').lower():
183
+ chunks = ErrorFrames(self.key).stream(chunks)
184
+ async for chunk in chunks:
185
+ yield chunk
186
+ finally:
187
+ await upstream.aclose()
188
+ return StreamingResponse(body(), status_code=upstream.status_code, headers=headers)
189
+
190
+ async def close(self):
191
+ if self.server is not None:
192
+ self.server.should_exit = True
193
+ # The owning native process has already closed; cancel only this
194
+ # listener's in-flight transports, including a silent upstream.
195
+ tasks = tuple(self.server.server_state.tasks)
196
+ for task in tasks: task.cancel()
197
+ if tasks: await asyncio.gather(*tasks, return_exceptions=True)
198
+ if self.task is not None:
199
+ # Let Uvicorn close its asyncio listeners and remove their readers.
200
+ # Cancelling serve() skips shutdown and can leave a stale selector
201
+ # entry when the next provider reuses the same socket descriptor.
202
+ with suppress(asyncio.CancelledError): await self.task
203
+ if self.client is not None: await self.client.aclose()
204
+ if self.socket is not None: self.socket.close()
205
+ self.task = self.server = self.client = self.socket = None
206
+
207
+
208
+ class CustomCodexAppServerManager(CodexAppServerManager):
209
+ """Native manager with an owned, credential-scoped stream normalizer."""
210
+ def __init__(self, *args, selected: dict, **kwargs):
211
+ self.response_bridge = ResponseStreamBridge(selected)
212
+ prepare = kwargs.pop('before_start', None)
213
+ environment = kwargs.pop('env_factory')
214
+ original_args = tuple(kwargs.get('app_server_args', ()))
215
+ async def before_start():
216
+ if prepare is not None: await prepare()
217
+ await self.response_bridge.start()
218
+ self.client.app_server_args = (*original_args, *config_args({
219
+ f'model_providers.{PROVIDER_ID}.base_url': self.response_bridge.url}))
220
+ def env_factory():
221
+ return {**environment(), ENV_KEY: self.response_bridge.token}
222
+ kwargs['sensitive_values'] = (*kwargs.get('sensitive_values', ()), self.response_bridge.token)
223
+ super().__init__(*args, before_start=before_start, env_factory=env_factory, **kwargs)
224
+
225
+ async def close(self):
226
+ try:
227
+ await super().close()
228
+ finally:
229
+ await self.response_bridge.close()
package/server/install.sh CHANGED
@@ -1839,7 +1839,7 @@ if [[ "$TEAM_HUB_MODE" == "host" && "$TEAM_HUB_OPERATION_PENDING" != "true" \
1839
1839
  fi
1840
1840
  fi
1841
1841
 
1842
- RELEASE_FILES=(activation_transaction.py execution_activation.py execution_legacy_runner.py execution_preparation.py update_preparation.py update_handoff.py update_recovery.py execution_update_status.py execution_uninstall.py execution_recovery.py execution_recovery_status.py execution_http.py execution_durability.py execution_control.py execution_install.py execution_maintenance.py execution_manage.py execution_ownership.py execution_service.py execution_transport.py agent_server.py local_session_ownership.py cursor_history.py workspace_git.py team_hub_host.py secure_peer_runtime.py team_mail_runtime.py team_mail_websocket.py team_mail_grants.py secure_peer_delivery.py agentsdock_jobs.py agentsdock_chats.py chat_mailbox.py agentsdock_emergency.py agentsdock_publish.py agentsdock_mail.py agentsdock_team.py provider_commands.py provider_usage.py claude_sdk_client.py claude_model_catalog.py claude_goals.py claude_background_reconciliation.py codex_app_server.py codex_auth.py codex_provider.py provider_connections.py cursor_api_key.py side_questions.py title_generation.py codex_side_question.py codex_side_chat_progress.py claude_side_question.py cursor_agent_client.py cursor_provider_mcp.py opencode_agent_client.py cursor_process_guard.py claude_history_repair.py claude_history_provenance.py codex_history_repair.py public_chat_shares.py public_chat_transcript.py public_chat_share_routes.py interactive_chat_shares.py interactive_chat_share_routes.py interactive_chat_share_web.py interactive_chat_projection.py interactive_chat_runtime.py interactive_chat_native.py shared_chat_videos.py shared_chat_video_stream.py interactive_chat_controls.py install.sh uninstall.sh instances.sh server_instances.py update_runner.py pyproject.toml uv.lock VERSION release-public-key.pem LICENSE NOTICE)
1842
+ RELEASE_FILES=(activation_transaction.py execution_activation.py execution_legacy_runner.py execution_preparation.py update_preparation.py update_handoff.py update_recovery.py execution_update_status.py execution_uninstall.py execution_recovery.py execution_recovery_status.py execution_http.py execution_durability.py execution_control.py execution_install.py execution_maintenance.py execution_manage.py execution_ownership.py execution_service.py execution_transport.py agent_server.py local_session_ownership.py cursor_history.py workspace_git.py team_hub_host.py secure_peer_runtime.py team_mail_runtime.py team_mail_websocket.py team_mail_grants.py secure_peer_delivery.py agentsdock_jobs.py agentsdock_chats.py chat_mailbox.py agentsdock_emergency.py agentsdock_publish.py agentsdock_mail.py agentsdock_team.py provider_commands.py provider_usage.py claude_sdk_client.py claude_model_catalog.py claude_goals.py claude_background_reconciliation.py codex_app_server.py codex_auth.py codex_provider.py codex_response_stream.py provider_connections.py cursor_api_key.py side_questions.py title_generation.py codex_side_question.py codex_side_chat_progress.py claude_side_question.py cursor_agent_client.py cursor_provider_mcp.py opencode_agent_client.py cursor_process_guard.py claude_history_repair.py claude_history_provenance.py codex_history_repair.py public_chat_shares.py public_chat_transcript.py public_chat_share_routes.py interactive_chat_shares.py interactive_chat_share_routes.py interactive_chat_share_web.py interactive_chat_projection.py interactive_chat_runtime.py interactive_chat_native.py shared_chat_videos.py shared_chat_video_stream.py interactive_chat_controls.py install.sh uninstall.sh instances.sh server_instances.py update_runner.py pyproject.toml uv.lock VERSION release-public-key.pem LICENSE NOTICE)
1843
1843
  RELEASE_DIRECTORIES=(agentsdock_team_hub)
1844
1844
  TEAM_HUB_RELEASE_FILES=(
1845
1845
  __init__.py
@@ -3751,7 +3751,7 @@ validate_staged_release_runtime() (
3751
3751
  "$STAGE_DIR/claude_background_reconciliation.py" \
3752
3752
  "$STAGE_DIR/codex_app_server.py" \
3753
3753
  "$STAGE_DIR/codex_auth.py" \
3754
- "$STAGE_DIR/codex_provider.py" \
3754
+ "$STAGE_DIR/codex_provider.py" "$STAGE_DIR/codex_response_stream.py" \
3755
3755
  "$STAGE_DIR/provider_connections.py" "$STAGE_DIR/cursor_api_key.py" \
3756
3756
  "$STAGE_DIR/side_questions.py" \
3757
3757
  "$STAGE_DIR/workspace_git.py" \
@@ -3781,7 +3781,7 @@ validate_staged_release_runtime() (
3781
3781
  "$STAGE_DIR/update_runner.py"
3782
3782
  "$STAGE_DIR/.venv/bin/python" -m py_compile "$STAGE_DIR/server_instances.py"
3783
3783
  "$STAGE_DIR/.venv/bin/python" -m compileall -q "$STAGE_DIR/agentsdock_team_hub"
3784
- PYTHONPATH="$STAGE_DIR" "$STAGE_DIR/.venv/bin/python" -c 'import execution_activation, execution_preparation, update_preparation, update_handoff, update_recovery, execution_update_status, execution_uninstall, execution_recovery, execution_recovery_status, execution_http, execution_durability, execution_control, execution_install, execution_maintenance, execution_manage, execution_ownership, execution_service, execution_transport; import workspace_git, claude_model_catalog, server_instances, local_session_ownership, cursor_history; import codex_auth, codex_provider, provider_connections, cursor_api_key, side_questions, title_generation, codex_side_question, codex_side_chat_progress, claude_side_question, agentsdock_team_hub, cursor_agent_client, cursor_provider_mcp, opencode_agent_client, cursor_process_guard, secure_peer_delivery, secure_peer_runtime, team_mail_runtime, team_mail_websocket, team_mail_grants, team_hub_host, agentsdock_mail, agentsdock_team, claude_history_repair, claude_history_provenance, claude_goals, claude_background_reconciliation, codex_history_repair, public_chat_shares, public_chat_transcript, public_chat_share_routes, interactive_chat_shares, interactive_chat_share_routes, interactive_chat_share_web, interactive_chat_projection, interactive_chat_runtime, interactive_chat_native, shared_chat_videos, shared_chat_video_stream, interactive_chat_controls, chat_mailbox, provider_commands, provider_usage; from agentsdock_team_hub import secure_peer, secure_peer_hub' >/dev/null
3784
+ PYTHONPATH="$STAGE_DIR" "$STAGE_DIR/.venv/bin/python" -c 'import execution_activation, execution_preparation, update_preparation, update_handoff, update_recovery, execution_update_status, execution_uninstall, execution_recovery, execution_recovery_status, execution_http, execution_durability, execution_control, execution_install, execution_maintenance, execution_manage, execution_ownership, execution_service, execution_transport; import workspace_git, claude_model_catalog, server_instances, local_session_ownership, cursor_history; import codex_auth, codex_provider, codex_response_stream, provider_connections, cursor_api_key, side_questions, title_generation, codex_side_question, codex_side_chat_progress, claude_side_question, agentsdock_team_hub, cursor_agent_client, cursor_provider_mcp, opencode_agent_client, cursor_process_guard, secure_peer_delivery, secure_peer_runtime, team_mail_runtime, team_mail_websocket, team_mail_grants, team_hub_host, agentsdock_mail, agentsdock_team, claude_history_repair, claude_history_provenance, claude_goals, claude_background_reconciliation, codex_history_repair, public_chat_shares, public_chat_transcript, public_chat_share_routes, interactive_chat_shares, interactive_chat_share_routes, interactive_chat_share_web, interactive_chat_projection, interactive_chat_runtime, interactive_chat_native, shared_chat_videos, shared_chat_video_stream, interactive_chat_controls, chat_mailbox, provider_commands, provider_usage; from agentsdock_team_hub import secure_peer, secure_peer_hub' >/dev/null
3785
3785
  )
3786
3786
 
3787
3787
  abort_unclaimed_team_hub_reactivation() {
@@ -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
@@ -611,6 +614,147 @@ def show(instance: Instance, *, network: dict[str, str] | None = None):
611
614
  print()
612
615
 
613
616
 
617
+ def show_status(instance: Instance, *, network: dict[str, str]) -> None:
618
+ """Read-only per-instance summary; never print configuration or tokens."""
619
+ item = describe(instance)
620
+ print(terminal_color(f"Server ({item['name']}):", "34"))
621
+ print(f" Status: {item['status']}")
622
+ addresses = []
623
+ if item["port"]:
624
+ choices = connection_choices(read_config(instance).get("AGENTSDOCK_AGENT_BIND", "0.0.0.0"),
625
+ item["port"], item["addresses"], network)
626
+ if choices["tailscale"]:
627
+ addresses.append(f"{choices['tailscale']} (Tailscale / other networks)")
628
+ for key, label in (("lan", "Same Wi-Fi / LAN"), ("local", "This machine only"),
629
+ ("other", "Other / unverified")):
630
+ addresses.extend(f"{url} ({label})" for url in choices[key])
631
+ print(" Address: " + (addresses[0] if addresses else "Not configured"))
632
+ for address in addresses[1:]:
633
+ print(" " + address)
634
+ print(f" Version: {item['version'] or 'unknown'}")
635
+ print(f" Port: {item['port'] or 'Not configured'}")
636
+ print()
637
+
638
+
639
+ def read_token_menu_key(fd: int) -> str:
640
+ key = os.read(fd, 1)
641
+ if key in (b"", b"\x03", b"\x04"):
642
+ return "cancel"
643
+ if key in (b"\r", b"\n"):
644
+ return "enter"
645
+ if key != b"\x1b":
646
+ return "other"
647
+ sequence = b""
648
+ while len(sequence) < 16 and select.select([fd], [], [], 0.15)[0]:
649
+ part = os.read(fd, 1)
650
+ if not part:
651
+ return "cancel"
652
+ sequence += part
653
+ if sequence in (b"[A", b"OA"):
654
+ return "up"
655
+ if sequence in (b"[B", b"OB"):
656
+ return "down"
657
+ if sequence[:1] not in (b"[", b"O") or (len(sequence) > 1 and 0x40 <= part[0] <= 0x7e):
658
+ return "other"
659
+ return "cancel" if not sequence else "other"
660
+
661
+
662
+ def arrow_token_choice(rows: list[str], fd: int, settings: list) -> int | None:
663
+ """Render a bounded terminal menu and restore input mode before token output."""
664
+ size = shutil.get_terminal_size(fallback=(80, 24))
665
+ visible = min(len(rows), max(1, size.lines - 6), 10)
666
+ width = max(1, size.columns - 1)
667
+ selected, start = 0, 0
668
+ hint = "Use ↑/↓ to choose; Enter to select; Esc to cancel."
669
+
670
+ def render(redraw=False):
671
+ if redraw:
672
+ sys.stdout.write(f"\x1b[{visible + 1}A")
673
+ for index in range(start, start + visible):
674
+ row = (" > " if index == selected else " ") + rows[index]
675
+ row = row[:width]
676
+ if index == selected:
677
+ row = terminal_color(row, "1;36")
678
+ sys.stdout.write("\r\x1b[2K" + row + "\n")
679
+ sys.stdout.write("\r\x1b[2K" + hint[:width] + "\n")
680
+ sys.stdout.flush()
681
+
682
+ try:
683
+ tty.setcbreak(fd, termios.TCSANOW)
684
+ sys.stdout.write("\x1b[?25l")
685
+ render()
686
+ while True:
687
+ key = read_token_menu_key(fd)
688
+ if key == "cancel":
689
+ return None
690
+ if key == "enter":
691
+ return selected
692
+ if key not in ("up", "down"):
693
+ continue
694
+ selected = (selected + (1 if key == "down" else -1)) % len(rows)
695
+ start = min(start, selected) if selected < start else max(start, selected - visible + 1)
696
+ render(redraw=True)
697
+ except KeyboardInterrupt:
698
+ return None
699
+ finally:
700
+ termios.tcsetattr(fd, termios.TCSANOW, settings)
701
+ sys.stdout.write("\x1b[?25h\n")
702
+ sys.stdout.flush()
703
+
704
+
705
+ def choose_token_instance(registry: Registry) -> Instance | None:
706
+ """Select by stable instance name; never default or expose unselected tokens."""
707
+ items = registry.instances()
708
+ if not items:
709
+ print("No installations found. Run agentsdock setup to create your first server.")
710
+ return None
711
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
712
+ raise ValueError("Choose a server in an interactive terminal, or run agentsdock token NAME "
713
+ "(for example: agentsdock token default). No token was shown.")
714
+ print("\nChoose a server to view its access token:\n")
715
+ rows = []
716
+ for index, instance in enumerate(items, 1):
717
+ config = read_config(instance)
718
+ port = config.get("AGENTSDOCK_AGENT_PORT") or "not configured"
719
+ rows.append(f"{instance.name:<20} {service_status(instance):<10} Port: {port}")
720
+ if os.environ.get("TERM", "dumb") not in ("", "dumb"):
721
+ try:
722
+ fd = sys.stdin.fileno()
723
+ settings = termios.tcgetattr(fd)
724
+ except (OSError, ValueError, termios.error):
725
+ pass # Basic terminals retain the explicit numbered/name prompt.
726
+ else:
727
+ index = arrow_token_choice(rows, fd, settings)
728
+ if index is None:
729
+ print("Cancelled; no token was shown.")
730
+ return None
731
+ selected = items[index]
732
+ if selected.name not in {item.name for item in registry.instances()}:
733
+ raise ValueError("That server is no longer available. Run agentsdock token again. No token was shown.")
734
+ return selected
735
+ for index, row in enumerate(rows, 1):
736
+ print(f" {index}. {row}")
737
+ print()
738
+ names = {item.name: item for item in items}
739
+ while True:
740
+ try:
741
+ answer = input("Enter a number or server name (Enter to cancel): ").strip()
742
+ except (EOFError, KeyboardInterrupt):
743
+ print()
744
+ answer = ""
745
+ if not answer:
746
+ print("Cancelled; no token was shown.")
747
+ return None
748
+ selected = names.get(answer)
749
+ if re.fullmatch(r"[1-9][0-9]{0,5}", answer) and int(answer) <= len(items):
750
+ selected = items[int(answer) - 1]
751
+ if selected is not None:
752
+ if selected.name not in {item.name for item in registry.instances()}:
753
+ raise ValueError("That server is no longer available. Run agentsdock token again. No token was shown.")
754
+ return selected
755
+ print(f"Please enter a number from 1 to {len(items)} or a listed server name (Enter cancels).")
756
+
757
+
614
758
  def terminal_color(text: str, code: str) -> str:
615
759
  color = sys.stdout.isatty() and os.environ.get("TERM") != "dumb" and "NO_COLOR" not in os.environ
616
760
  return f"\033[{code}m{text}\033[0m" if color else text
@@ -747,8 +891,11 @@ def parser() -> argparse.ArgumentParser:
747
891
  result = argparse.ArgumentParser(description="Independent AgentsServer instances for the current OS user. Defaults to list.")
748
892
  commands = result.add_subparsers(dest="command")
749
893
  commands.add_parser("list")
894
+ commands.add_parser("token", help="Choose an existing server and display only its access token")
750
895
  info = commands.add_parser("info")
751
896
  info.add_argument("name", type=instance_name)
897
+ status = commands.add_parser("status", help="Show readable details for all instances or one name")
898
+ status.add_argument("name", nargs="?", type=instance_name)
752
899
  new = commands.add_parser("new")
753
900
  new.add_argument("--name", type=instance_name)
754
901
  new.add_argument("--port", type=int)
@@ -785,13 +932,30 @@ def main(argv: list[str] | None = None) -> int:
785
932
  validate_binding(instance)
786
933
  print(instance.shell_bindings())
787
934
  return 0
788
- if args.command in {None, "list", "info"}:
935
+ if args.command == "token":
936
+ selected = choose_token_instance(registry)
937
+ if selected is None:
938
+ return 0
939
+ try:
940
+ run(["/bin/bash", str(ROOT / "install.sh"), "--instance", selected.name, "--show-token"],
941
+ env=clean_environment(selected), cwd=ROOT)
942
+ return 0
943
+ except subprocess.CalledProcessError as exc:
944
+ return exc.returncode if exc.returncode > 0 else 1
945
+ if args.command in {None, "list", "info", "status"}:
789
946
  items = registry.instances()
790
- if args.command == "info":
947
+ if args.command in {"info", "status"} and args.name:
791
948
  items = [item for item in items if item.name == args.name]
792
949
  if not items:
793
950
  raise ValueError("Unknown instance.")
951
+ if args.command == "info":
794
952
  print(json.dumps(describe(items[0]), indent=2))
953
+ elif args.command == "status":
954
+ network = tailscale_status() if items else None
955
+ for item in items:
956
+ show_status(item, network=network)
957
+ if not items:
958
+ print("No installations found. Run agentsdock setup to create your first server.")
795
959
  else:
796
960
  network = tailscale_status() if items else None
797
961
  print("NAME STATUS PORT")