@agentsdock/server 1.0.11-beta.1 → 1.0.11-beta.2

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.
@@ -157,6 +157,7 @@ def exclusive_lock(path: Path):
157
157
  PROVIDER_ID_FIELDS = {
158
158
  "claude": "claude_session_id", "codex": "codex_thread_id",
159
159
  "cursor": "cursor_session_id", "opencode": "opencode_session_id",
160
+ "gemini": "gemini_session_id",
160
161
  }
161
162
  MAX_IMPORT_INDEX_BYTES = 64 * 1024 * 1024
162
163
  MAX_IMPORT_INDEX_TOTAL_BYTES = 128 * 1024 * 1024
@@ -987,6 +987,71 @@ def claude_provider_command_inventory(
987
987
  )
988
988
 
989
989
 
990
+ def gemini_provider_skill_inventory(raw: str, *, cwd: str, selector_secret: str,
991
+ binding_context: str) -> ProviderCommandInventory:
992
+ """Project the pinned CLI's own resolved inventory, not a guessed scanner.
993
+
994
+ CLI 0.64 has no JSON skills command. Parse its exact bounded list format and
995
+ fail closed on ambiguous output. Paths/content stay server-side; invocation
996
+ asks Gemini's native activate_skill tool and never grants a tool permission.
997
+ """
998
+ records = []
999
+ text = raw.replace("\r\n", "\n")
1000
+ if text.strip() == "No skills discovered.":
1001
+ return _inventory(selector_secret, binding_context, "gemini", cwd, [], truncated=False)
1002
+ header = "Discovered Agent Skills:\n\n"
1003
+ if not text.startswith(header) or len(text.encode()) > 1024 * 1024:
1004
+ raise ProviderCommandDiscoveryError("Gemini skill inventory format is unsupported")
1005
+ blocks = text[len(header):].rstrip("\n").split("\n\n")
1006
+ if len(blocks) > MAX_PROVIDER_COMMANDS:
1007
+ raise ProviderCommandDiscoveryError("Gemini skill inventory exceeded its limit")
1008
+ seen = set()
1009
+ total = 0
1010
+ for block in blocks:
1011
+ match = re.fullmatch(r"([^\n]+) \[(Enabled|Disabled)\]( \[Built-in\])?\n Description: ([\s\S]*?)\n Location: ([^\n]+)", block)
1012
+ if not match:
1013
+ raise ProviderCommandDiscoveryError("Gemini skill inventory format is unsupported")
1014
+ name, enabled, builtin, description, location = match.groups()
1015
+ if enabled == "Disabled":
1016
+ continue
1017
+ # Native names can be more permissive than our slash-token grammar.
1018
+ # Such skills remain usable by the agent but are not offered as tokens.
1019
+ if canonical_provider_command_name(name) is None:
1020
+ continue
1021
+ if name.casefold() in seen:
1022
+ raise ProviderCommandDiscoveryError("Gemini skill names are ambiguous")
1023
+ seen.add(name.casefold())
1024
+ path = Path(location)
1025
+ if not path.is_absolute() or path.name != "SKILL.md" or len(location) > MAX_PROVIDER_COMMAND_PATH_CHARS:
1026
+ raise ProviderCommandDiscoveryError("Gemini returned an invalid skill location")
1027
+ # Resolve native installed links, then open only a regular, bounded file.
1028
+ # Discovery/revalidation is read-only; never use this path as a command.
1029
+ path = path.resolve(strict=True)
1030
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
1031
+ try:
1032
+ info = os.fstat(descriptor)
1033
+ if not stat.S_ISREG(info.st_mode) or info.st_size > 1024 * 1024:
1034
+ raise ProviderCommandDiscoveryError("Gemini skill file is unsupported")
1035
+ data = os.read(descriptor, 1024 * 1024 + 1)
1036
+ after = os.fstat(descriptor)
1037
+ if len(data) > 1024 * 1024 or (info.st_size, info.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
1038
+ raise ProviderCommandDiscoveryError("Gemini skill changed during discovery")
1039
+ finally:
1040
+ os.close(descriptor)
1041
+ total += len(data)
1042
+ if total > 16 * 1024 * 1024:
1043
+ raise ProviderCommandDiscoveryError("Gemini skills exceeded the aggregate limit")
1044
+ digest = hashlib.sha256(data).hexdigest()
1045
+ command_id = _opaque_command_id(selector_secret, binding_context, "gemini", cwd,
1046
+ name, json.dumps([str(path), digest]))
1047
+ records.append(ProviderCommandRecord(public={
1048
+ "id": command_id, "name": name, "label": name,
1049
+ "description": sanitize_provider_command_text(description, MAX_PROVIDER_COMMAND_DESCRIPTION_CHARS),
1050
+ "scope": "built-in" if builtin else "native", "source": "gemini", "kind": "skill", "invocation": f"/{name}",
1051
+ }, native={"name": name, "path": str(path), "content_sha256": digest}))
1052
+ return _inventory(selector_secret, binding_context, "gemini", cwd, records, truncated=False)
1053
+
1054
+
990
1055
  def empty_provider_command_inventory(
991
1056
  backend: str,
992
1057
  *,
@@ -28,7 +28,7 @@ MAX_BODY_BYTES = 16 * 1024
28
28
  # Reasoning can consume output tokens before the short visible check answer.
29
29
  # Keep this explicit probe bounded and single-shot, including for such models.
30
30
  MODEL_CHECK_MAX_OUTPUT_TOKENS = 256
31
- PROTOCOLS = {"claude": {"anthropic"}, "opencode": {"anthropic", "chat_completions", "responses"}, "cursor": {"cursor"}}
31
+ PROTOCOLS = {"claude": {"anthropic"}, "opencode": {"anthropic", "chat_completions", "responses"}, "cursor": {"cursor"}, "gemini": {"gemini"}}
32
32
  RESULTS = {"verified", "authentication_failed", "rate_limited", "unsupported", "connection_failed", "invalid_response", "model_required", "cli_update_required"}
33
33
 
34
34
 
@@ -52,9 +52,11 @@ def selection(backend: str, value: object) -> dict:
52
52
  from cursor_api_key import ENDPOINT
53
53
  if value["base_url"] != ENDPOINT or value["protocol"] != "cursor" or value["auth_header"] != "bearer":
54
54
  raise HTTPException(400, "Cursor API keys use Cursor's official service only.")
55
- if not isinstance(value["protocol"], str) or not isinstance(value["auth_header"], str) or value["protocol"] not in PROTOCOLS[backend] or value["auth_header"] not in {"bearer", "x-api-key"}:
55
+ if not isinstance(value["protocol"], str) or not isinstance(value["auth_header"], str) or value["protocol"] not in PROTOCOLS[backend] or value["auth_header"] not in {"bearer", "x-api-key", "x-goog-api-key"}:
56
56
  raise HTTPException(400, "Unsupported API protocol or authentication header.")
57
- if value["protocol"] != "anthropic" and value["auth_header"] != "bearer":
57
+ if (value["protocol"] == "gemini") != (value["auth_header"] == "x-goog-api-key"):
58
+ raise HTTPException(400, "Gemini API requires x-goog-api-key authentication.")
59
+ if value["protocol"] not in {"anthropic", "gemini"} and value["auth_header"] != "bearer":
58
60
  raise HTTPException(400, "This API protocol requires bearer authentication.")
59
61
  selected = validate_selection({"base_url": value["base_url"], "api_key": value["api_key"],
60
62
  "model": None if value["model"] == "" else value["model"]})
@@ -207,11 +209,15 @@ class ConnectionStore:
207
209
  try:
208
210
  value = self.for_session(session) if session else self.read(backend)
209
211
  configured = value.get("configured") is True and value.get("last_result") == "verified"
212
+ model = value.get("model")
213
+ if configured and backend == "gemini":
214
+ from gemini_api import default_model
215
+ model = default_model(value)
210
216
  if summary:
211
217
  # Large endpoint inventories belong to detail/settings reads,
212
218
  # not repeated once for every row of a session-list response.
213
219
  return {"configured": configured, "available": configured and installed,
214
- "model": value.get("model"), "base_url": value.get("base_url")}
220
+ "model": model, "base_url": value.get("base_url")}
215
221
  models = []
216
222
  if configured:
217
223
  cache_key = (backend, value["revision"])
@@ -225,12 +231,14 @@ class ConnectionStore:
225
231
  self.catalog_cache.pop(next(iter(self.catalog_cache)))
226
232
  if backend == "cursor" and not models:
227
233
  models = [{"value": "auto", "label": "Auto"}]
228
- model = value.get("model")
234
+ if backend == "gemini":
235
+ from gemini_api import model_options
236
+ models = model_options(value, models)
229
237
  if model and not any(item["value"] == model for item in models):
230
238
  models = [{"value": model, "label": model}, *models]
231
239
  return {"configured": configured, "available": configured and installed,
232
- "models": models, "efforts": [], "default_model": value.get("model"),
233
- "model": value.get("model"), "default_effort": None, "base_url": value.get("base_url")}
240
+ "models": models, "efforts": [], "default_model": model,
241
+ "model": model, "default_effort": None, "base_url": value.get("base_url")}
234
242
  except HTTPException:
235
243
  return {"configured": False, "available": False, "models": [], "efforts": [], "model": None, "base_url": None}
236
244
 
@@ -317,6 +325,9 @@ def require_model(value: dict) -> str:
317
325
 
318
326
 
319
327
  def discover_models(value: dict) -> list[dict]:
328
+ if value["protocol"] == "gemini":
329
+ from gemini_api import catalog
330
+ return catalog(value)["models"]
320
331
  return endpoint_model_catalog(value, protocol=value["protocol"])["models"]
321
332
 
322
333
 
@@ -355,6 +366,9 @@ def native_credentials_present(backend: str, diagnostic: dict) -> bool:
355
366
 
356
367
  async def probe_credentials(selected: dict) -> str:
357
368
  """Read-only key check. Public model lists alone never verify a credential."""
369
+ if selected.get("protocol") == "gemini":
370
+ from gemini_api import probe as gemini_probe
371
+ return await gemini_probe({**selected, "model": None})
358
372
  base = selected["base_url"]
359
373
  protocol = selected.get("protocol", "responses")
360
374
  headers = {"Accept": "application/json", "anthropic-version": "2023-06-01"}
@@ -397,6 +411,9 @@ async def probe_credentials(selected: dict) -> str:
397
411
 
398
412
  async def probe(selected: dict) -> str:
399
413
  """One small, explicit model request; no tools/history, redirects or retries."""
414
+ if selected.get("protocol") == "gemini":
415
+ from gemini_api import probe as gemini_probe
416
+ return await gemini_probe(selected)
400
417
  if not selected.get("model"):
401
418
  return await probe_credentials(selected)
402
419
  protocol = selected["protocol"]
@@ -546,7 +563,7 @@ def create_router(*, authorize, store: ConnectionStore, check=probe, account=nat
546
563
  return JSONResponse(value, headers={"Cache-Control": "no-store"})
547
564
 
548
565
  async def model_selection(backend, session_id=None):
549
- if backend not in {"codex", "claude", "opencode", "cursor"}:
566
+ if backend not in {"codex", "claude", "opencode", "cursor", "gemini"}:
550
567
  raise HTTPException(400, "Unsupported custom endpoint.")
551
568
  session = session_lookup(session_id) if session_id and session_lookup else None
552
569
  if session_id and (not session or session.get("backend") != backend):
@@ -567,6 +584,10 @@ def create_router(*, authorize, store: ConnectionStore, check=probe, account=nat
567
584
  if backend == "cursor":
568
585
  if cursor_catalog is None: raise HTTPException(501, "Cursor API keys are unavailable.")
569
586
  result = await cursor_catalog(selected)
587
+ elif backend == "gemini":
588
+ from gemini_api import catalog, model_options
589
+ result = await asyncio.to_thread(catalog, selected)
590
+ result["models"] = model_options(selected, result["models"])
570
591
  else:
571
592
  result = await asyncio.to_thread(endpoint_model_catalog, selected, protocol=selected.get("protocol", "responses"))
572
593
  # Fence a settings read that raced replacement/forget. Session bindings
@@ -581,8 +602,12 @@ def create_router(*, authorize, store: ConnectionStore, check=probe, account=nat
581
602
  with store.lock:
582
603
  store.catalog_cache[(backend, selected["revision"])] = (time.monotonic(), result["models"])
583
604
  if len(store.catalog_cache) > 32: store.catalog_cache.pop(next(iter(store.catalog_cache)))
605
+ model = selected.get("model")
606
+ if backend == "gemini":
607
+ from gemini_api import default_model
608
+ model = default_model(selected)
584
609
  return reply({"backend": backend, "revision": selected.get("credential_id") if backend == "codex" else selected["revision"],
585
- "models": result["models"], "default_model": selected.get("model"), "discovery_status": result["discovery_status"]})
610
+ "models": result["models"], "default_model": model, "discovery_status": result["discovery_status"]})
586
611
 
587
612
  @router.put("/api/admin/provider-models/{backend}")
588
613
  async def default_model(backend: str, request: Request):
@@ -611,7 +636,11 @@ def create_router(*, authorize, store: ConnectionStore, check=probe, account=nat
611
636
  saved = store.write(backend, current["revision"], {**value, "model": model, "expected_revision": current["revision"]}, current["last_result"], checked_at=current["checked_at"])
612
637
  cached = store.catalog_cache.get((backend, current["revision"]))
613
638
  if cached: store.catalog_cache[(backend, saved["revision"])] = cached
614
- return {"backend": backend, "revision": saved["revision"], "default_model": saved["model"]}
639
+ effective_model = saved["model"]
640
+ if backend == "gemini":
641
+ from gemini_api import default_model as gemini_default_model
642
+ effective_model = gemini_default_model(saved)
643
+ return {"backend": backend, "revision": saved["revision"], "default_model": effective_model}
615
644
  return reply(await asyncio.to_thread(save_model))
616
645
 
617
646
  @router.get("/api/admin/provider-accounts/{backend}")
@@ -666,11 +666,8 @@ def show(instance: Instance, *, network: dict[str, str] | None = None):
666
666
  print()
667
667
 
668
668
 
669
- def show_status(instance: Instance, *, network: dict[str, str]) -> None:
670
- """Read-only per-instance summary; never print configuration or tokens."""
671
- item = describe(instance)
672
- print(terminal_color(f"Server ({item['name']}):", "34"))
673
- print(f" Status: {item['status']}")
669
+ def show_connection_addresses(instance: Instance, item: dict, network: dict[str, str]) -> None:
670
+ """Use the same binding-aware address order for status and token display."""
674
671
  addresses = []
675
672
  if item["port"]:
676
673
  choices = connection_choices(read_config(instance).get("AGENTSDOCK_AGENT_BIND", "0.0.0.0"),
@@ -683,11 +680,150 @@ def show_status(instance: Instance, *, network: dict[str, str]) -> None:
683
680
  print(" Address: " + (addresses[0] if addresses else "Not configured"))
684
681
  for address in addresses[1:]:
685
682
  print(" " + address)
683
+
684
+
685
+ def show_status(instance: Instance, *, network: dict[str, str]) -> None:
686
+ """Read-only per-instance summary; never print configuration or tokens."""
687
+ item = describe(instance)
688
+ print(terminal_color(f"Server ({item['name']}):", "34"))
689
+ print(f" Status: {item['status']}")
690
+ show_connection_addresses(instance, item, network)
686
691
  print(f" Version: {item['version'] or 'unknown'}")
687
692
  print(f" Port: {item['port'] or 'Not configured'}")
688
693
  print()
689
694
 
690
695
 
696
+ LOG_READ_BYTES = 1024 * 1024
697
+
698
+
699
+ def log_line_count(value: str) -> int:
700
+ if not re.fullmatch(r"[0-9]{1,5}", value) or int(value) > 10000:
701
+ raise argparse.ArgumentTypeError("Log lines must be between 0 and 10000.")
702
+ return int(value)
703
+
704
+
705
+ def log_sources(instance: Instance, platform: str) -> tuple[list[Path], list[str]]:
706
+ """Resolve fixed local log sources; never execute configuration or adopt paths."""
707
+ manifest = instance.runtime / "execution-layout.json"
708
+ check_path(manifest, instance.home)
709
+ if manifest.exists():
710
+ from execution_install import installed_layout
711
+
712
+ layout = installed_layout(instance.runtime)
713
+ if (instance.name != "default" or layout.install_root != instance.runtime
714
+ or layout.config_root != instance.config or layout.state_root != instance.state
715
+ or layout.home != instance.home
716
+ or layout.platform != ("Darwin" if platform == "darwin" else "Linux")):
717
+ raise ValueError("Execution log layout does not match the selected instance.")
718
+ return ([instance.state / "execution/logs" / f"{role}.{stream}.log"
719
+ for role in ("gateway", "worker") for stream in ("stdout", "stderr")],
720
+ ["agents-server-gateway.service", "agents-server.service"])
721
+ validate_binding(instance, platform)
722
+ return ([instance.logs / "server.log", instance.logs / "server-error.log"],
723
+ [service_name(instance.name) + ".service"])
724
+
725
+
726
+ def read_log_slice(instance: Instance, path: Path, lines: int,
727
+ cursor: tuple[int, int, int] | None = None) -> tuple[bytes, tuple[int, int, int] | None, bool]:
728
+ """Bounded tail on first read, then bounded appended bytes; reopen on rotation.
729
+
730
+ Revalidate every open, including followed replacements. No shell tail process,
731
+ FIFO, symlink or file outside the selected fixed log roots is followed.
732
+ """
733
+ check_path(path, instance.home)
734
+ try:
735
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
736
+ except FileNotFoundError:
737
+ # Remember that viewing has begun so a file created during --follow is
738
+ # read from its start, including with --lines 0.
739
+ return b"", cursor or (0, 0, 0), False
740
+ with os.fdopen(fd, "rb") as handle:
741
+ info = os.fstat(handle.fileno())
742
+ if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
743
+ or info.st_mode & 0o022 or info.st_nlink != 1):
744
+ raise ValueError(f"Unsafe service log: {path.name}")
745
+ identity = (info.st_dev, info.st_ino)
746
+ truncated = False
747
+ if cursor is None:
748
+ start = max(0, info.st_size - LOG_READ_BYTES) if lines else info.st_size
749
+ truncated = bool(start and lines)
750
+ elif cursor[:2] == identity and info.st_size >= cursor[2]:
751
+ start = cursor[2]
752
+ else:
753
+ start = 0 # The file was replaced or truncated, not a new history scan.
754
+ handle.seek(start)
755
+ data = handle.read(min(LOG_READ_BYTES, max(0, info.st_size - start)))
756
+ next_cursor = (*identity, start + len(data))
757
+ if cursor is None:
758
+ if truncated:
759
+ _partial, separator, rest = data.partition(b"\n")
760
+ data = rest if separator else b""
761
+ data = b"\n".join(data.split(b"\n")[-lines - (1 if data.endswith(b"\n") else 0):]) if lines else b""
762
+ return data, next_cursor, truncated
763
+
764
+
765
+ def print_log_data(data: bytes) -> None:
766
+ # Logs are untrusted text; do not execute terminal escape/control sequences.
767
+ text = data.decode("utf-8", errors="replace")
768
+ text = "".join(f"\\x{ord(char):02x}" if (ord(char) < 32 and char not in "\n\t")
769
+ or 127 <= ord(char) < 160 else char for char in text)
770
+ print(text, end="" if text.endswith("\n") else "\n", flush=True)
771
+
772
+
773
+ def show_logs(instance: Instance, *, lines: int = 100, follow: bool = False,
774
+ platform: str = sys.platform) -> int:
775
+ if platform != "darwin" and not platform.startswith("linux"):
776
+ raise ValueError("Service logs are supported on macOS and Linux only.")
777
+ paths, units = log_sources(instance, platform)
778
+ port = read_config(instance).get("AGENTSDOCK_AGENT_PORT", "")
779
+ print(terminal_color(f"Logs ({instance.name}):", "34"), flush=True)
780
+ if port.isdigit() and 1 <= int(port) <= 65535:
781
+ print(f" Port: {int(port)}", flush=True)
782
+ print(" Logs may contain private data. Review before sharing.", flush=True)
783
+ if follow:
784
+ print(" Following new entries; Ctrl+C stops viewing, not the server.", flush=True)
785
+ if platform.startswith("linux"):
786
+ print(f" Last {lines} journal entries across: {', '.join(units)}\n", flush=True)
787
+ command = ["journalctl", "--user", "--no-pager", "--output=short-iso", "--lines", str(lines)]
788
+ for unit in units:
789
+ command += ["--unit", unit]
790
+ if follow:
791
+ command.append("--follow")
792
+ try:
793
+ result = subprocess.run(command, env={**os.environ, "SYSTEMD_COLORS": "0",
794
+ "SYSTEMD_PAGERSECURE": "1"}, check=False,
795
+ timeout=None if follow else 15)
796
+ return result.returncode if result.returncode >= 0 else 130
797
+ except subprocess.TimeoutExpired:
798
+ raise ValueError("Reading the service journal timed out. Try again with fewer lines.") from None
799
+ except KeyboardInterrupt:
800
+ print("\nStopped viewing logs.", flush=True)
801
+ return 0
802
+ print(f" Last {lines} lines per file (up to 1 MiB per file).\n", flush=True)
803
+ cursors: dict[Path, tuple[int, int, int] | None] = {path: None for path in paths}
804
+ first = True
805
+ try:
806
+ while True:
807
+ for path in paths:
808
+ data, cursor, truncated = read_log_slice(instance, path, lines, cursors[path])
809
+ cursors[path] = cursor
810
+ if first or data or truncated:
811
+ print(f"==> {path.name} <==", flush=True)
812
+ if truncated:
813
+ print("[Older bytes omitted: initial read is limited to 1 MiB.]", flush=True)
814
+ if data:
815
+ print_log_data(data)
816
+ elif first:
817
+ print("(no log entries to show)", flush=True)
818
+ if not follow:
819
+ return 0
820
+ first = False
821
+ time.sleep(0.25)
822
+ except KeyboardInterrupt:
823
+ print("\nStopped viewing logs.", flush=True)
824
+ return 0
825
+
826
+
691
827
  def read_token_menu_key(fd: int) -> str:
692
828
  key = os.read(fd, 1)
693
829
  if key in (b"", b"\x03", b"\x04"):
@@ -754,9 +890,10 @@ def arrow_token_choice(rows: list[str], fd: int, settings: list) -> int | None:
754
890
  sys.stdout.flush()
755
891
 
756
892
 
757
- def choose_token_instance(registry: Registry) -> Instance | None:
893
+ def choose_token_instance(registry: Registry, items: list[Instance] | None = None) -> Instance | None:
758
894
  """Select by stable instance name; never default or expose unselected tokens."""
759
- items = registry.instances()
895
+ if items is None:
896
+ items = registry.instances()
760
897
  if not items:
761
898
  print("No installations found. Run agentsdock setup to create your first server.")
762
899
  return None
@@ -807,6 +944,34 @@ def choose_token_instance(registry: Registry) -> Instance | None:
807
944
  print(f"Please enter a number from 1 to {len(items)} or a listed server name (Enter cancels).")
808
945
 
809
946
 
947
+ def show_instance_token(instance: Instance, *, network: dict[str, str] | None = None) -> int:
948
+ # Redirected explicit token commands retain their raw, single-token output.
949
+ if sys.stdin.isatty() and sys.stdout.isatty():
950
+ print(terminal_color(f"\nServer ({instance.name}):", "34"))
951
+ show_connection_addresses(instance, describe(instance), network if network is not None else tailscale_status())
952
+ sys.stdout.flush() # Keep the URL before the child helper's token/prompt.
953
+ try:
954
+ run(["/bin/bash", str(ROOT / "install.sh"), "--instance", instance.name, "--show-token"],
955
+ env=clean_environment(instance), cwd=ROOT)
956
+ return 0
957
+ except subprocess.CalledProcessError as exc:
958
+ return exc.returncode if exc.returncode > 0 else 1
959
+
960
+
961
+ def offer_status_token(registry: Registry, items: list[Instance], network: dict[str, str]) -> int:
962
+ if not items or not (sys.stdin.isatty() and sys.stdout.isatty()):
963
+ return 0
964
+ try:
965
+ answer = input("View a server's URL and access token? [y/N] ").strip().lower()
966
+ except (EOFError, KeyboardInterrupt):
967
+ print()
968
+ return 0
969
+ if answer not in {"y", "yes"}:
970
+ return 0
971
+ selected = choose_token_instance(registry, items)
972
+ return show_instance_token(selected, network=network) if selected is not None else 0
973
+
974
+
810
975
  def terminal_color(text: str, code: str) -> str:
811
976
  color = sys.stdout.isatty() and os.environ.get("TERM") != "dumb" and "NO_COLOR" not in os.environ
812
977
  return f"\033[{code}m{text}\033[0m" if color else text
@@ -952,13 +1117,19 @@ def parser() -> argparse.ArgumentParser:
952
1117
  result = argparse.ArgumentParser(description="Independent AgentsServer instances for the current OS user. Defaults to list.")
953
1118
  commands = result.add_subparsers(dest="command")
954
1119
  commands.add_parser("list")
955
- commands.add_parser("token", help="Choose an existing server and display only its access token")
1120
+ token = commands.add_parser("token", help="Choose an existing server and display its URL and access token")
1121
+ token.add_argument("name", nargs="?", type=instance_name)
956
1122
  info = commands.add_parser("info")
957
1123
  info.add_argument("name", type=instance_name)
958
1124
  pairing = commands.add_parser("_pair-info", help=argparse.SUPPRESS)
959
1125
  pairing.add_argument("name", type=instance_name)
960
1126
  status = commands.add_parser("status", help="Show readable details for all instances or one name")
961
1127
  status.add_argument("name", nargs="?", type=instance_name)
1128
+ logs = commands.add_parser("logs", help="Read recent local service logs; use --follow for live output")
1129
+ logs.add_argument("name", nargs="?", type=instance_name)
1130
+ logs.add_argument("--instance", dest="named", type=instance_name)
1131
+ logs.add_argument("-n", "--lines", type=log_line_count, default=100)
1132
+ logs.add_argument("-f", "--follow", action="store_true")
962
1133
  new = commands.add_parser("new")
963
1134
  new.add_argument("--name", type=instance_name)
964
1135
  new.add_argument("--port", type=int)
@@ -1001,16 +1172,24 @@ def main(argv: list[str] | None = None) -> int:
1001
1172
  raise ValueError("Unknown instance. Use agentsdock list.")
1002
1173
  print(json.dumps(pairing_info(selected)))
1003
1174
  return 0
1175
+ if args.command == "logs":
1176
+ if args.name and args.named:
1177
+ raise ValueError("Select the log instance once: NAME or --instance NAME.")
1178
+ name = args.name or args.named or "default"
1179
+ selected = next((item for item in registry.instances() if item.name == name), None)
1180
+ if selected is None:
1181
+ raise ValueError(f"Unknown instance {name!r}. Use agentsdock list to find its name.")
1182
+ return show_logs(selected, lines=args.lines, follow=args.follow)
1004
1183
  if args.command == "token":
1005
- selected = choose_token_instance(registry)
1184
+ if args.name:
1185
+ # The installer retains read-only legacy token discovery even
1186
+ # when an old default install is absent from the new registry.
1187
+ selected = Instance(args.name, registry.home)
1188
+ else:
1189
+ selected = choose_token_instance(registry)
1006
1190
  if selected is None:
1007
1191
  return 0
1008
- try:
1009
- run(["/bin/bash", str(ROOT / "install.sh"), "--instance", selected.name, "--show-token"],
1010
- env=clean_environment(selected), cwd=ROOT)
1011
- return 0
1012
- except subprocess.CalledProcessError as exc:
1013
- return exc.returncode if exc.returncode > 0 else 1
1192
+ return show_instance_token(selected)
1014
1193
  if args.command in {None, "list", "info", "status"}:
1015
1194
  items = registry.instances()
1016
1195
  if args.command in {"info", "status"} and args.name:
@@ -1025,6 +1204,7 @@ def main(argv: list[str] | None = None) -> int:
1025
1204
  show_status(item, network=network)
1026
1205
  if not items:
1027
1206
  print("No installations found. Run agentsdock setup to create your first server.")
1207
+ return offer_status_token(registry, items, network)
1028
1208
  else:
1029
1209
  network = tailscale_status() if items else None
1030
1210
  print("NAME STATUS PORT")
@@ -13,6 +13,9 @@ import sys
13
13
  import tempfile
14
14
  import time
15
15
  import unicodedata
16
+ from urllib.parse import quote
17
+
18
+ import httpx
16
19
 
17
20
  from codex_app_server import CodexAppServerClient
18
21
  from codex_side_question import isolated_config
@@ -35,6 +38,12 @@ class TitleGenerationError(Exception):
35
38
  """Keep errors content-free: prompts, credentials and output are private."""
36
39
 
37
40
 
41
+ def gemini_title_seed(text: str) -> str:
42
+ # Pasted logs often put the actual question at the end. Keep the same
43
+ # bounded budget as other providers without storing the full log twice.
44
+ return text if len(text) <= 1600 else text[:780] + '\n[excerpt omitted]\n' + text[-800:]
45
+
46
+
38
47
  def title_prompt(user_text: str, reply: str) -> str:
39
48
  return INSTRUCTIONS + "\n\n" + json.dumps(
40
49
  {"user_message": user_text[:1600], "assistant_reply": reply[:800]},
@@ -329,8 +338,58 @@ async def generate_cursor_title(prompt: str, *, executable: str, model: str | No
329
338
  return clean_title(result)
330
339
 
331
340
 
341
+ async def generate_gemini_title(prompt: str, *, model: str | None, provider_selection: dict) -> str | None:
342
+ """One tool-free API request on the exact chat binding; no native session.
343
+
344
+ No CLI, hooks, MCP, filesystem tools, account fallback or model discovery.
345
+ The caller resolves CLI Auto from this conversation's native model metadata.
346
+ """
347
+ from gemini_api import MODEL, api_base
348
+
349
+ if (not isinstance(model, str) or model == 'auto' or not MODEL.fullmatch(model)
350
+ or provider_selection.get('protocol') != 'gemini'
351
+ or provider_selection.get('auth_header') != 'x-goog-api-key'
352
+ or not provider_selection.get('api_key')):
353
+ raise TitleGenerationError('Gemini title connection/model unavailable')
354
+ try:
355
+ async with httpx.AsyncClient(timeout=TIMEOUT_SECONDS, follow_redirects=False, trust_env=False) as client:
356
+ async with client.stream('POST', api_base(provider_selection) + '/models/' + quote(model, safe='') + ':generateContent',
357
+ headers={'x-goog-api-key': provider_selection['api_key']},
358
+ json={'contents': [{'role': 'user', 'parts': [{'text': prompt}]}],
359
+ 'generationConfig': {'maxOutputTokens': 1024, 'candidateCount': 1}}) as response:
360
+ if response.status_code != 200:
361
+ raise TitleGenerationError('Gemini title request failed')
362
+ raw = bytearray()
363
+ async for chunk in response.aiter_bytes(chunk_size=8192):
364
+ raw.extend(chunk)
365
+ if len(raw) > 64 * 1024:
366
+ raise TitleGenerationError('Excessive Gemini title output')
367
+ value = json.loads(raw)
368
+ candidates = value.get('candidates')
369
+ if not isinstance(candidates, list) or len(candidates) != 1 or candidates[0].get('finishReason') != 'STOP':
370
+ raise TitleGenerationError('Gemini title request did not complete')
371
+ parts = candidates[0].get('content', {}).get('parts')
372
+ if not isinstance(parts, list) or not parts or len(parts) > 32:
373
+ raise TitleGenerationError('Invalid Gemini title output')
374
+ text = []
375
+ for part in parts:
376
+ if (not isinstance(part, dict) or not isinstance(part.get('text'), str)
377
+ or set(part) - {'text', 'thought', 'thoughtSignature'}
378
+ or ('thought' in part and not isinstance(part['thought'], bool))):
379
+ raise TitleGenerationError('Unexpected Gemini title content')
380
+ if part.get('thought') is not True:
381
+ text.append(part['text'])
382
+ title = clean_title(''.join(text))
383
+ if title and provider_selection['api_key'] in title:
384
+ return None
385
+ return title
386
+ except (httpx.HTTPError, ValueError, TypeError, AttributeError, KeyError):
387
+ raise TitleGenerationError('Gemini title request failed') from None
388
+
389
+
332
390
  async def generate_title(backend: str, user_text: str, reply: str, **options) -> str | None:
333
- generator = {'codex': generate_codex_title, 'cursor': generate_cursor_title}.get(backend)
391
+ generator = {'codex': generate_codex_title, 'cursor': generate_cursor_title,
392
+ 'gemini': generate_gemini_title}.get(backend)
334
393
  if generator is None:
335
394
  return None
336
395
  return await asyncio.wait_for(generator(title_prompt(user_text, reply), **options), TIMEOUT_SECONDS)
@@ -109,12 +109,15 @@ class RawRecord:
109
109
 
110
110
 
111
111
  class _RecordParser:
112
- def __init__(self, stream, end, deadline, cancelled, project, maximum, budget_seconds):
112
+ def __init__(self, stream, end, deadline, cancelled, project, maximum, budget_seconds, retained_fields=None, max_nodes=-1, max_depth=None):
113
113
  self.stream, self.end = stream, end
114
114
  self.deadline, self.cancelled, self.project = deadline, cancelled, project
115
115
  self.buffer, self.index, self.final, self.complete = b'', 0, False, False
116
116
  self.retained, self.nodes, self.maximum = 0, 0, maximum
117
117
  self.budget_seconds = budget_seconds
118
+ self.retained_fields = retained_fields
119
+ self.max_nodes = MAX_NODES if max_nodes == -1 else max_nodes
120
+ self.max_depth = MAX_DEPTH if max_depth is None else max_depth
118
121
 
119
122
  def fill(self):
120
123
  if self.index < len(self.buffer):
@@ -207,15 +210,17 @@ class _RecordParser:
207
210
 
208
211
  def value(self, path=(), retain=True, depth=0):
209
212
  self.nodes += 1
210
- if depth > MAX_DEPTH:
213
+ if depth > self.max_depth:
211
214
  raise TranscriptBudgetExceeded(
212
215
  'Single transcript record exceeds the JSON nesting limit: '
213
- f'observed depth {depth:,}; limit {MAX_DEPTH:,}.')
214
- if self.nodes > MAX_NODES:
216
+ f'observed depth {depth:,}; limit {self.max_depth:,}.')
217
+ if self.max_nodes is not None and self.nodes > self.max_nodes:
215
218
  raise TranscriptBudgetExceeded(
216
219
  'Single transcript record exceeds the JSON value-count limit: '
217
- f'observed at least {self.nodes:,} values; limit {MAX_NODES:,}.')
220
+ f'observed at least {self.nodes:,} values; limit {self.max_nodes:,}.')
218
221
  self.space()
222
+ if path and self.retained_fields is not None and path[0] not in self.retained_fields:
223
+ retain = False
219
224
  if self.project and opaque_path(path):
220
225
  retain = False
221
226
  if retain:
@@ -235,7 +240,11 @@ class _RecordParser:
235
240
  while True:
236
241
  if obj:
237
242
  self.space()
238
- key = self.string(True, key=True)
243
+ # Explicit metadata projection does not need keys inside
244
+ # discarded subtrees. Do not charge transient key bytes as
245
+ # retained metadata (native import behavior is unchanged).
246
+ keep_key = retain or self.retained_fields is None or self.project
247
+ key = self.string(keep_key, key=True)
239
248
  self.space()
240
249
  if self.take() != 58:
241
250
  raise InvalidRecord('JSON colon expected')
@@ -271,12 +280,15 @@ class _RecordParser:
271
280
 
272
281
  def jsonl_records(stream: BinaryIO, *, end: int, project=True, strict=False,
273
282
  require_newline=True, max_records=250_000, deadline=None,
274
- cancelled=None, max_retained_bytes=MAX_RETAINED_BYTES):
283
+ cancelled=None, max_retained_bytes=MAX_RETAINED_BYTES, retained_fields=None,
284
+ max_nodes=-1, max_depth=None):
275
285
  """Yield (projected object or None, ending offset, RawRecord).
276
286
 
277
287
  Malformed provider lines may be represented by None, as before. Resource
278
288
  exhaustion always raises, so callers never commit a partial-success cursor.
279
289
  A fixed end excludes concurrent appends; callers own file identity checks.
290
+ Native-import work budgets remain the default. Durable metadata-only scans
291
+ can supply separate structural limits without imposing import caps on logs.
280
292
  """
281
293
  # Externally supplied deadlines can belong to a larger operation. Do not
282
294
  # invent a duration for them; the caller can provide its own timeout detail.
@@ -291,7 +303,7 @@ def jsonl_records(stream: BinaryIO, *, end: int, project=True, strict=False,
291
303
  'Transcript scan exceeds the record-count limit: '
292
304
  f'observed at least {count:,} records; limit {max_records:,}. '
293
305
  'This counts JSONL records, not chat messages.').at_record(count, start)
294
- parser = _RecordParser(stream, end, deadline, cancelled, project, max_retained_bytes, budget_seconds)
306
+ parser = _RecordParser(stream, end, deadline, cancelled, project, max_retained_bytes, budget_seconds, retained_fields, max_nodes, max_depth)
295
307
  blank = False
296
308
  malformed = False
297
309
  try: