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

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.
Files changed (38) hide show
  1. package/NOTICE +7 -0
  2. package/package.json +1 -1
  3. package/server/NOTICE +7 -0
  4. package/server/VERSION +1 -1
  5. package/server/agent_server.py +1474 -318
  6. package/server/agentsdock_jobs.py +2 -2
  7. package/server/codex_auth.py +48 -0
  8. package/server/codex_history_repair.py +180 -15
  9. package/server/cursor_process_guard.py +67 -4
  10. package/server/cursor_provider_mcp.py +2 -0
  11. package/server/execution_durability.py +87 -11
  12. package/server/execution_install.py +5 -1
  13. package/server/gemini_acp_client.py +432 -0
  14. package/server/gemini_api.py +128 -0
  15. package/server/gemini_content.py +102 -0
  16. package/server/gemini_history.py +293 -0
  17. package/server/gemini_import.py +126 -0
  18. package/server/gemini_permissions.py +47 -0
  19. package/server/gemini_runner.py +493 -0
  20. package/server/gemini_runtime.py +322 -0
  21. package/server/install.sh +25 -2
  22. package/server/interactive_chat_controls.py +9 -3
  23. package/server/interactive_chat_native.py +1 -1
  24. package/server/interactive_chat_share_routes.py +2 -2
  25. package/server/interactive_chat_share_web.py +2 -2
  26. package/server/local_session_ownership.py +1 -0
  27. package/server/npm/agentsdock/QR-NOTICES.txt +56 -0
  28. package/server/npm/agentsdock/pair.cjs +123 -0
  29. package/server/npm/agentsdock/qr-renderer.cjs +15 -0
  30. package/server/provider_commands.py +65 -0
  31. package/server/provider_connections.py +39 -10
  32. package/server/public_chat_share_routes.py +8 -6
  33. package/server/public_chat_shares.py +17 -7
  34. package/server/server_instances.py +195 -15
  35. package/server/shared_chat_analytics_web.py +5 -0
  36. package/server/title_generation.py +60 -1
  37. package/server/transcript_reader.py +20 -8
  38. package/server/update_runner.py +4 -2
@@ -26,6 +26,7 @@ import time
26
26
  from typing import Any, Callable, Iterator
27
27
 
28
28
  from shared_chat_videos import normalize_shared_chat_videos
29
+ from shared_chat_analytics_web import SCRIPT as _ANALYTICS_SCRIPT, CSP_HASH as _ANALYTICS_HASH
29
30
 
30
31
 
31
32
  # Pagination targets, not quotas: one complete message may exceed a page target.
@@ -644,16 +645,21 @@ def _render_message_markdown(text: str) -> str:
644
645
  return "".join(parts)
645
646
 
646
647
 
647
- def public_chat_share_headers(*, allow_unlock_form: bool = False) -> dict[str, str]:
648
+ def public_chat_share_headers(*, allow_unlock_form: bool = False, analytics: bool = False) -> dict[str, str]:
648
649
  """Use these headers for both successful viewer pages and unavailable pages."""
650
+ analytics = analytics and not allow_unlock_form
649
651
  return {
650
652
  "Content-Type": "text/html; charset=utf-8",
651
653
  "Content-Security-Policy": (
652
- "default-src 'none'; script-src 'none'; "
653
- f"style-src 'sha256-{_STYLE_HASH}'; "
654
- "img-src 'none'; media-src 'self'; connect-src 'none'; base-uri 'none'; "
654
+ "default-src 'none'; "
655
+ + (f"script-src 'sha256-{_ANALYTICS_HASH}'; " if analytics else "script-src 'none'; ")
656
+ + f"style-src 'sha256-{_STYLE_HASH}'; "
657
+ "img-src 'none'; media-src 'self'; "
658
+ + ("connect-src https://api.mixpanel.com; " if analytics else "connect-src 'none'; ")
659
+ + "base-uri 'none'; "
655
660
  + ("form-action 'self'; frame-ancestors 'none'; sandbox allow-forms allow-same-origin"
656
- if allow_unlock_form else "form-action 'none'; frame-ancestors 'none'; sandbox allow-same-origin")
661
+ if allow_unlock_form else "form-action 'none'; frame-ancestors 'none'; sandbox allow-same-origin"
662
+ + (" allow-scripts" if analytics else ""))
657
663
  ),
658
664
  "Cache-Control": "no-store",
659
665
  # A native navigation form under no-referrer submits Origin: null in
@@ -694,7 +700,8 @@ def render_public_chat_unlock_html(share_id: str, *, invalid_token: bool = False
694
700
 
695
701
 
696
702
  def render_public_chat_html(snapshot: dict[str, Any], *, page: int = 0, page_count: int = 1,
697
- message_count: int | None = None, navigation_base: str | None = None) -> bytes:
703
+ message_count: int | None = None, navigation_base: str | None = None,
704
+ analytics: bool = False) -> bytes:
698
705
  """Render a static transcript with escaped, presentation-only Markdown."""
699
706
  if not isinstance(snapshot, dict) or set(snapshot) != {"title", "created_at", "messages"}:
700
707
  raise PublicChatShareValidationError("Invalid public snapshot fields.")
@@ -753,5 +760,8 @@ def render_public_chat_html(snapshot: dict[str, Any], *, page: int = 0, page_cou
753
760
  f'<figcaption>{filename}</figcaption></figure>')
754
761
  parts.append('</div>')
755
762
  parts.append('</section>')
756
- parts.append(f'</div>{navigation}<footer class="conversation-footer"><p>This is a saved copy. Replies and updates stay in the original chat.</p><p class="footer-brand">Shared with AgentsDock</p></footer><div id="conversation-end"></div></main></body></html>')
763
+ parts.append(f'</div>{navigation}<footer class="conversation-footer"><p>This is a saved copy. Replies and updates stay in the original chat.</p><p class="footer-brand">Shared with AgentsDock</p></footer><div id="conversation-end"></div></main>')
764
+ if analytics:
765
+ parts.append(f'<script data-share-analytics>{_ANALYTICS_SCRIPT}</script>')
766
+ parts.append('</body></html>')
757
767
  return "".join(parts).encode("utf-8")
@@ -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")
@@ -0,0 +1,5 @@
1
+ """Generated from the limited shared-chat analytics entry; do not edit by hand."""
2
+ import json
3
+
4
+ SCRIPT = json.loads("\"/*! @noble/hashes\\nThe MIT License (MIT)\\n\\nCopyright (c) 2022 Paul Miller (https://paulmillr.com)\\n\\nPermission is hereby granted, free of charge, to any person obtaining a copy\\nof this software and associated documentation files (the “Software”), to deal\\nin the Software without restriction, including without limitation the rights\\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\\ncopies of the Software, and to permit persons to whom the Software is\\nfurnished to do so, subject to the following conditions:\\n\\nThe above copyright notice and this permission notice shall be included in\\nall copies or substantial portions of the Software.\\n\\nTHE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\\nTHE SOFTWARE.\\n*/(function(){\\\"use strict\\\";function H(e){return e instanceof Uint8Array||ArrayBuffer.isView(e)&&e.constructor.name===\\\"Uint8Array\\\"&&\\\"BYTES_PER_ELEMENT\\\"in e&&e.BYTES_PER_ELEMENT===1}function p(e,t,s=\\\"\\\"){const n=H(e),r=e?.length;if(!n||t!==void 0){const i=s&&`\\\"${s}\\\" `,o=\\\"\\\",f=n?`length=${r}`:`type=${typeof e}`,h=i+\\\"expected Uint8Array\\\"+o+\\\", got \\\"+f;throw n?new RangeError(h):new TypeError(h)}return e}function m(e,t=!0){if(e.destroyed)throw new Error(\\\"Hash instance has been destroyed\\\");if(t&&e.finished)throw new Error(\\\"Hash#digest() has already been called\\\")}function S(e,t){p(e,void 0,\\\"digestInto() output\\\");const s=t.outputLen;if(e.length<s)throw new RangeError('\\\"digestInto() output\\\" expected to be of length >='+s)}function y(...e){for(let t=0;t<e.length;t++)e[t].fill(0)}function _(e){return new DataView(e.buffer,e.byteOffset,e.byteLength)}function d(e,t){return e<<32-t|e>>>t}const T=typeof Uint8Array.from([]).toHex==\\\"function\\\"&&typeof Uint8Array.fromHex==\\\"function\\\",k=Array.from({length:256},(e,t)=>t.toString(16).padStart(2,\\\"0\\\"));function B(e){if(p(e),T)return e.toHex();let t=\\\"\\\";for(let s=0;s<e.length;s++)t+=k[e[s]];return t}function O(e){if(typeof e!=\\\"string\\\")throw new TypeError(\\\"string expected\\\");return new Uint8Array(new TextEncoder().encode(e))}function U(e,t={}){const s=(r,c)=>e(c).update(r).digest(),n=e(void 0);return s.outputLen=n.outputLen,s.blockLen=n.blockLen,s.canXOF=n.canXOF,s.create=r=>e(r),Object.assign(s,t),Object.freeze(s)}const v=e=>({oid:Uint8Array.from([6,9,96,134,72,1,101,3,4,2,e])});function C(e,t,s){return e&t^~e&s}function I(e,t,s){return e&t^e&s^t&s}class D{blockLen;outputLen;canXOF=!1;padOffset;isLE;buffer;view;finished=!1;length=0;pos=0;destroyed=!1;constructor(t,s,n,r){this.blockLen=t,this.outputLen=s,this.padOffset=n,this.isLE=r,this.buffer=new Uint8Array(t),this.view=_(this.buffer)}update(t){m(this),p(t);const{view:s,buffer:n,blockLen:r}=this,c=t.length;for(let i=0;i<c;){const o=Math.min(r-this.pos,c-i);if(o===r){const f=_(t);for(;r<=c-i;i+=r)this.process(f,i);continue}n.set(t.subarray(i,i+o),this.pos),this.pos+=o,i+=o,this.pos===r&&(this.process(s,0),this.pos=0)}return this.length+=t.length,this.roundClean(),this}digestInto(t){m(this),S(t,this),this.finished=!0;const{buffer:s,view:n,blockLen:r,isLE:c}=this;let{pos:i}=this;s[i++]=128,y(this.buffer.subarray(i)),this.padOffset>r-i&&(this.process(n,0),i=0);for(let a=i;a<r;a++)s[a]=0;n.setBigUint64(r-8,BigInt(this.length*8),c),this.process(n,0);const o=_(t),f=this.outputLen;if(f%4)throw new Error(\\\"_sha2: outputLen must be aligned to 32bit\\\");const h=f/4,l=this.get();if(h>l.length)throw new Error(\\\"_sha2: outputLen bigger than state\\\");for(let a=0;a<h;a++)o.setUint32(4*a,l[a],c)}digest(){const{buffer:t,outputLen:s}=this;this.digestInto(t);const n=t.slice(0,s);return this.destroy(),n}_cloneInto(t){t||=new this.constructor,t.set(...this.get());const{blockLen:s,buffer:n,length:r,finished:c,destroyed:i,pos:o}=this;return t.destroyed=i,t.finished=c,t.length=r,t.pos=o,r%s&&t.buffer.set(n),t}clone(){return this._cloneInto()}}const x=Uint32Array.from([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]),N=Uint32Array.from([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]),u=new Uint32Array(64);class F extends D{constructor(t){super(64,t,8,!1)}get(){const{A:t,B:s,C:n,D:r,E:c,F:i,G:o,H:f}=this;return[t,s,n,r,c,i,o,f]}set(t,s,n,r,c,i,o,f){this.A=t|0,this.B=s|0,this.C=n|0,this.D=r|0,this.E=c|0,this.F=i|0,this.G=o|0,this.H=f|0}process(t,s){for(let a=0;a<16;a++,s+=4)u[a]=t.getUint32(s,!1);for(let a=16;a<64;a++){const g=u[a-15],b=u[a-2],L=d(g,7)^d(g,18)^g>>>3,w=d(b,17)^d(b,19)^b>>>10;u[a]=w+u[a-7]+L+u[a-16]|0}let{A:n,B:r,C:c,D:i,E:o,F:f,G:h,H:l}=this;for(let a=0;a<64;a++){const g=d(o,6)^d(o,11)^d(o,25),b=l+g+C(o,f,h)+N[a]+u[a]|0,w=(d(n,2)^d(n,13)^d(n,22))+I(n,r,c)|0;l=h,h=f,f=o,o=i+b|0,i=c,c=r,r=n,n=b+w|0}n=n+this.A|0,r=r+this.B|0,c=c+this.C|0,i=i+this.D|0,o=o+this.E|0,f=f+this.F|0,h=h+this.G|0,l=l+this.H|0,this.set(n,r,c,i,o,f,h,l)}roundClean(){y(u)}destroy(){this.destroyed=!0,this.set(0,0,0,0,0,0,0,0),y(this.buffer)}}class M extends F{A=x[0]|0;B=x[1]|0;C=x[2]|0;D=x[3]|0;E=x[4]|0;F=x[5]|0;G=x[6]|0;H=x[7]|0;constructor(){super(32)}}const V=U(()=>new M,v(1)),$=[\\\"shared_chat_snapshot_activated\\\",\\\"shared_chat_interactive_activated\\\",\\\"shared_chat_message_sent\\\"],E=\\\"agentsdock:shared-analytics-seen:v1\\\",A=new Set;function G(e,t,s=\\\"\\\"){try{if(!$.includes(e)||!/^\\\\/(?:interactive-chat\\\\/interactive_[a-f0-9]{32}|shared-chat\\\\/share_[a-f0-9]{32}|share\\\\/[A-Za-z0-9_-]{43})$/.test(t)||e===\\\"shared_chat_message_sent\\\"&&!/^[A-Za-z0-9_-]{16,128}$/.test(s)||navigator.doNotTrack===\\\"1\\\"||navigator.globalPrivacyControl||localStorage.getItem(\\\"agentsdock:analytics-consent:v1\\\")===\\\"denied\\\")return;const n=B(V(O(JSON.stringify([e,t,s])))),r=JSON.parse(sessionStorage.getItem(E)??\\\"[]\\\"),c=Array.isArray(r)?r.filter(f=>typeof f==\\\"string\\\"&&/^[a-f0-9]{64}$/.test(f)):[];if(A.has(n)||c.includes(n)||c.length>=4096)return;sessionStorage.setItem(E,JSON.stringify([...c,n])),A.add(n);const i=new AbortController,o=setTimeout(()=>i.abort(),5e3);try{fetch(\\\"https://api.mixpanel.com/track?ip=0\\\",{method:\\\"POST\\\",credentials:\\\"omit\\\",referrerPolicy:\\\"no-referrer\\\",headers:{\\\"Content-Type\\\":\\\"application/json\\\"},signal:i.signal,body:JSON.stringify([{event:e,properties:{token:\\\"66b4fef625e5750d5527870f0ca96d5e\\\",distinct_id:\\\"\\\",platform:\\\"shared_web\\\",time:Math.floor(Date.now()/1e3)}}])}).catch(()=>{}).finally(()=>clearTimeout(o))}catch{clearTimeout(o)}}catch{}}G(\\\"shared_chat_snapshot_activated\\\",location.pathname)})();\"")
5
+ CSP_HASH = "mGoIxolAbIiMaBueHq0pZetv9HOC3PsCEy+QNKDYsaM="
@@ -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:
@@ -555,18 +555,20 @@ def server_work_snapshot(
555
555
  "server health response is missing its service-cgroup admission proof"
556
556
  )
557
557
  raw_count = cgroup.get("unknown_descendant_count")
558
+ inspection = cgroup.get("inspection")
558
559
  if (
559
560
  cgroup.get("safe") is not True
560
561
  or isinstance(raw_count, bool)
561
562
  or not isinstance(raw_count, int)
562
- or raw_count != 0
563
+ or raw_count < 0
564
+ or (raw_count != 0 and inspection != "verified-preserved")
563
565
  ):
564
566
  raise RuntimeError(
565
567
  "AgentsServer has an unverified or nonempty service cgroup"
566
568
  )
567
569
  if (
568
570
  require_verified_service_cgroup
569
- and cgroup.get("inspection") != "verified"
571
+ and inspection not in {"verified", "verified-preserved"}
570
572
  ):
571
573
  raise RuntimeError(
572
574
  "AgentsServer did not return a verified systemd service-cgroup proof"