@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.
- package/NOTICE +7 -0
- package/package.json +1 -1
- package/server/NOTICE +7 -0
- package/server/VERSION +1 -1
- package/server/agent_server.py +1421 -313
- package/server/agentsdock_jobs.py +2 -2
- package/server/codex_auth.py +48 -0
- package/server/codex_history_repair.py +180 -15
- package/server/cursor_process_guard.py +67 -4
- package/server/cursor_provider_mcp.py +2 -0
- package/server/execution_durability.py +87 -11
- package/server/execution_install.py +5 -1
- package/server/gemini_acp_client.py +432 -0
- package/server/gemini_api.py +128 -0
- package/server/gemini_content.py +102 -0
- package/server/gemini_history.py +293 -0
- package/server/gemini_import.py +126 -0
- package/server/gemini_permissions.py +47 -0
- package/server/gemini_runner.py +493 -0
- package/server/gemini_runtime.py +322 -0
- package/server/install.sh +10 -2
- package/server/interactive_chat_controls.py +9 -3
- package/server/interactive_chat_native.py +1 -1
- package/server/interactive_chat_share_routes.py +1 -1
- package/server/interactive_chat_share_web.py +2 -2
- package/server/local_session_ownership.py +1 -0
- package/server/provider_commands.py +65 -0
- package/server/provider_connections.py +39 -10
- package/server/server_instances.py +195 -15
- package/server/title_generation.py +60 -1
- package/server/transcript_reader.py +20 -8
- package/server/update_runner.py +4 -2
|
@@ -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"]
|
|
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":
|
|
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
|
-
|
|
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":
|
|
233
|
-
"model":
|
|
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":
|
|
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
|
-
|
|
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
|
|
670
|
-
"""
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 >
|
|
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 {
|
|
214
|
-
if self.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 {
|
|
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
|
-
|
|
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:
|