@agentsdock/server 1.0.10 → 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}")
@@ -587,6 +587,20 @@ def describe(instance: Instance) -> dict:
587
587
  }
588
588
 
589
589
 
590
+ def pairing_info(instance: Instance) -> dict:
591
+ """Private pipe to the CLI QR renderer; never write these details to logs."""
592
+ for target in (instance.config, instance.config / "env"):
593
+ check_path(target, instance.home)
594
+ env = read_config(instance)
595
+ token = env.get("AGENTSDOCK_AGENT_TOKEN", "")
596
+ if not token or any(ord(char) < 32 or ord(char) == 127 for char in token):
597
+ raise ValueError("This instance has no usable saved access token.")
598
+ port = int(env.get("AGENTSDOCK_AGENT_PORT", "7850"))
599
+ bind = env.get("AGENTSDOCK_AGENT_BIND", "0.0.0.0")
600
+ choices = connection_choices(bind, port, candidate_addresses(bind, port), tailscale_status())
601
+ return {"name": instance.name, "accessToken": token, "choices": choices}
602
+
603
+
590
604
  def connection_choices(bind: str, port: int, addresses: list[str], network: dict[str, str]) -> dict:
591
605
  """Label candidates, never infer Tailscale connectivity from an IP range."""
592
606
  choices = {"local": [], "lan": [], "other": [], "tailscale": "", "tailscale_note": ""}
@@ -652,11 +666,8 @@ def show(instance: Instance, *, network: dict[str, str] | None = None):
652
666
  print()
653
667
 
654
668
 
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']}")
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."""
660
671
  addresses = []
661
672
  if item["port"]:
662
673
  choices = connection_choices(read_config(instance).get("AGENTSDOCK_AGENT_BIND", "0.0.0.0"),
@@ -669,11 +680,150 @@ def show_status(instance: Instance, *, network: dict[str, str]) -> None:
669
680
  print(" Address: " + (addresses[0] if addresses else "Not configured"))
670
681
  for address in addresses[1:]:
671
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)
672
691
  print(f" Version: {item['version'] or 'unknown'}")
673
692
  print(f" Port: {item['port'] or 'Not configured'}")
674
693
  print()
675
694
 
676
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
+
677
827
  def read_token_menu_key(fd: int) -> str:
678
828
  key = os.read(fd, 1)
679
829
  if key in (b"", b"\x03", b"\x04"):
@@ -740,9 +890,10 @@ def arrow_token_choice(rows: list[str], fd: int, settings: list) -> int | None:
740
890
  sys.stdout.flush()
741
891
 
742
892
 
743
- def choose_token_instance(registry: Registry) -> Instance | None:
893
+ def choose_token_instance(registry: Registry, items: list[Instance] | None = None) -> Instance | None:
744
894
  """Select by stable instance name; never default or expose unselected tokens."""
745
- items = registry.instances()
895
+ if items is None:
896
+ items = registry.instances()
746
897
  if not items:
747
898
  print("No installations found. Run agentsdock setup to create your first server.")
748
899
  return None
@@ -793,6 +944,34 @@ def choose_token_instance(registry: Registry) -> Instance | None:
793
944
  print(f"Please enter a number from 1 to {len(items)} or a listed server name (Enter cancels).")
794
945
 
795
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
+
796
975
  def terminal_color(text: str, code: str) -> str:
797
976
  color = sys.stdout.isatty() and os.environ.get("TERM") != "dumb" and "NO_COLOR" not in os.environ
798
977
  return f"\033[{code}m{text}\033[0m" if color else text
@@ -938,11 +1117,19 @@ def parser() -> argparse.ArgumentParser:
938
1117
  result = argparse.ArgumentParser(description="Independent AgentsServer instances for the current OS user. Defaults to list.")
939
1118
  commands = result.add_subparsers(dest="command")
940
1119
  commands.add_parser("list")
941
- 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)
942
1122
  info = commands.add_parser("info")
943
1123
  info.add_argument("name", type=instance_name)
1124
+ pairing = commands.add_parser("_pair-info", help=argparse.SUPPRESS)
1125
+ pairing.add_argument("name", type=instance_name)
944
1126
  status = commands.add_parser("status", help="Show readable details for all instances or one name")
945
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")
946
1133
  new = commands.add_parser("new")
947
1134
  new.add_argument("--name", type=instance_name)
948
1135
  new.add_argument("--port", type=int)
@@ -979,16 +1166,30 @@ def main(argv: list[str] | None = None) -> int:
979
1166
  validate_binding(instance)
980
1167
  print(instance.shell_bindings())
981
1168
  return 0
1169
+ if args.command == "_pair-info":
1170
+ selected = next((item for item in registry.instances() if item.name == args.name), None)
1171
+ if selected is None:
1172
+ raise ValueError("Unknown instance. Use agentsdock list.")
1173
+ print(json.dumps(pairing_info(selected)))
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)
982
1183
  if args.command == "token":
983
- 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)
984
1190
  if selected is None:
985
1191
  return 0
986
- try:
987
- run(["/bin/bash", str(ROOT / "install.sh"), "--instance", selected.name, "--show-token"],
988
- env=clean_environment(selected), cwd=ROOT)
989
- return 0
990
- except subprocess.CalledProcessError as exc:
991
- return exc.returncode if exc.returncode > 0 else 1
1192
+ return show_instance_token(selected)
992
1193
  if args.command in {None, "list", "info", "status"}:
993
1194
  items = registry.instances()
994
1195
  if args.command in {"info", "status"} and args.name:
@@ -1003,6 +1204,7 @@ def main(argv: list[str] | None = None) -> int:
1003
1204
  show_status(item, network=network)
1004
1205
  if not items:
1005
1206
  print("No installations found. Run agentsdock setup to create your first server.")
1207
+ return offer_status_token(registry, items, network)
1006
1208
  else:
1007
1209
  network = tailscale_status() if items else None
1008
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)