@agentsdock/server 1.0.10 → 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 (39) 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 +1658 -498
  6. package/server/agentsdock_jobs.py +2 -2
  7. package/server/claude_history_repair.py +46 -61
  8. package/server/codex_auth.py +76 -7
  9. package/server/codex_history_repair.py +203 -38
  10. package/server/cursor_process_guard.py +67 -4
  11. package/server/cursor_provider_mcp.py +2 -0
  12. package/server/execution_durability.py +87 -11
  13. package/server/execution_install.py +5 -1
  14. package/server/gemini_acp_client.py +432 -0
  15. package/server/gemini_api.py +128 -0
  16. package/server/gemini_content.py +102 -0
  17. package/server/gemini_history.py +293 -0
  18. package/server/gemini_import.py +126 -0
  19. package/server/gemini_permissions.py +47 -0
  20. package/server/gemini_runner.py +493 -0
  21. package/server/gemini_runtime.py +322 -0
  22. package/server/install.sh +26 -2
  23. package/server/interactive_chat_controls.py +9 -3
  24. package/server/interactive_chat_native.py +1 -1
  25. package/server/interactive_chat_share_routes.py +2 -2
  26. package/server/interactive_chat_share_web.py +2 -2
  27. package/server/local_session_ownership.py +1 -0
  28. package/server/npm/agentsdock/QR-NOTICES.txt +56 -0
  29. package/server/npm/agentsdock/pair.cjs +123 -0
  30. package/server/npm/agentsdock/qr-renderer.cjs +15 -0
  31. package/server/provider_commands.py +65 -0
  32. package/server/provider_connections.py +39 -10
  33. package/server/public_chat_share_routes.py +8 -6
  34. package/server/public_chat_shares.py +17 -7
  35. package/server/server_instances.py +217 -15
  36. package/server/shared_chat_analytics_web.py +5 -0
  37. package/server/title_generation.py +60 -1
  38. package/server/transcript_reader.py +334 -0
  39. package/server/update_runner.py +4 -2
@@ -507,7 +507,7 @@ def build_parser() -> argparse.ArgumentParser:
507
507
  create_parser.add_argument("--loop", action="store_true", help="repeat at the interval")
508
508
  create_parser.add_argument("--max-runs", type=int)
509
509
  create_parser.add_argument("--disabled", action="store_true")
510
- create_parser.add_argument("--backend", choices=("codex", "claude", "cursor"))
510
+ create_parser.add_argument("--backend", choices=("codex", "claude", "cursor", "opencode", "gemini"))
511
511
  create_parser.add_argument(
512
512
  "--context-mode",
513
513
  choices=("chat", "standalone"),
@@ -541,7 +541,7 @@ def build_parser() -> argparse.ArgumentParser:
541
541
  run_limit_group = update_parser.add_mutually_exclusive_group()
542
542
  run_limit_group.add_argument("--max-runs", type=int)
543
543
  run_limit_group.add_argument("--unlimited", action="store_true", help="clear a finite run limit")
544
- update_parser.add_argument("--backend", choices=("codex", "claude", "cursor"))
544
+ update_parser.add_argument("--backend", choices=("codex", "claude", "cursor", "opencode", "gemini"))
545
545
  update_parser.add_argument(
546
546
  "--context-mode",
547
547
  choices=("chat", "standalone"),
@@ -14,6 +14,7 @@ import hashlib
14
14
  import hmac
15
15
  import json
16
16
  import os
17
+ import time
17
18
  from pathlib import Path
18
19
  import re
19
20
  import stat
@@ -26,9 +27,13 @@ from claude_goals import ClaudeGoalHistoryNormalizer, is_claude_synthetic_no_res
26
27
  from claude_sdk_client import CLAUDE_SDK_LITERAL_MESSAGE_PREFIX
27
28
 
28
29
 
30
+ from transcript_reader import jsonl_records, scan_check, CHUNK_BYTES, SCAN_SECONDS
31
+
29
32
  MAX_BYTES = 96 * 1024 * 1024
30
33
  MAX_EVENTS_BYTES = 32 * 1024 * 1024
31
- MAX_LINE_BYTES = 4 * 1024 * 1024
34
+ # Bound retained JSON; opaque provider payload bytes are streamed.
35
+ MAX_LINE_BYTES = 32 * 1024 * 1024
36
+ MAX_TEXT_CHARS = 4 * 1024 * 1024
32
37
  MAX_RECORDS = 100_000
33
38
  MAX_KEYS = 20_000
34
39
  MAX_TARGETS = 4_000
@@ -73,24 +78,15 @@ def _records(path: Path, expected: tuple[int, int, int, int]):
73
78
  info = os.fstat(stream.fileno())
74
79
  if (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns) != expected:
75
80
  raise _Unproven()
76
- count = 0
77
- offset = 0
78
- while offset < expected[2]:
79
- line = stream.readline(min(MAX_LINE_BYTES + 1, expected[2] - offset))
80
- if len(line) > MAX_LINE_BYTES or not line.endswith(b"\n"):
81
- raise _Unproven()
82
- count += 1
83
- if count > MAX_RECORDS:
84
- raise _Unproven()
85
- offset += len(line)
86
- event = json.loads(line)
87
- if not isinstance(event, dict):
88
- raise _Unproven()
89
- yield event, offset, line
81
+ try:
82
+ yield from jsonl_records(stream, end=expected[2], strict=True,
83
+ max_records=MAX_RECORDS, max_retained_bytes=MAX_LINE_BYTES)
84
+ except ValueError as exc:
85
+ raise _Unproven() from exc
90
86
  final = os.fstat(stream.fileno())
91
87
  if (final.st_dev, final.st_ino, final.st_size, final.st_mtime_ns) != expected:
92
88
  raise _Unproven()
93
- if _stamp(path) != expected:
89
+ if _regular_stamp(path) != expected:
94
90
  raise _Unproven()
95
91
 
96
92
 
@@ -106,7 +102,7 @@ def _target(event: dict) -> tuple[int, str, str] | None:
106
102
  event.get("type") != "turn_started" or event.get("imported") is not True
107
103
  or event.get("backend") != "claude" or type(seq) is not int or seq <= 0
108
104
  or not isinstance(run, str) or not run.startswith("import_")
109
- or not isinstance(prompt, str) or len(prompt) > MAX_LINE_BYTES
105
+ or not isinstance(prompt, str) or len(prompt) > MAX_TEXT_CHARS
110
106
  ):
111
107
  return None
112
108
  return seq, run, _text_key(prompt)
@@ -118,7 +114,7 @@ def _assistant_target(event: dict) -> tuple | None:
118
114
  if (event.get("type") not in ("assistant_text", "reasoning_summary")
119
115
  or event.get("imported") is not True or event.get("backend") != "claude"
120
116
  or type(seq) is not int or seq <= 0 or not isinstance(run, str) or not run.startswith("import_")
121
- or not isinstance(text, str) or not text.strip() or len(text) > MAX_LINE_BYTES
117
+ or not isinstance(text, str) or not text.strip() or len(text) > MAX_TEXT_CHARS
122
118
  or not isinstance(origin, dict) or origin.get("provider") != "claude"
123
119
  or origin.get("kind") not in (None, "assistant")
124
120
  or event.get("provider_user_authored") is True
@@ -173,7 +169,7 @@ class _AssistantReplays:
173
169
  return None
174
170
  display = self.normalize_assistant(text)
175
171
  return (_text_key(display) if isinstance(display, str) and display.strip()
176
- and len(display) <= MAX_LINE_BYTES else None)
172
+ and len(display) <= MAX_TEXT_CHARS else None)
177
173
 
178
174
  def event(self, event: dict) -> None:
179
175
  target = _assistant_target(event)
@@ -193,7 +189,7 @@ class _AssistantReplays:
193
189
  and (event.get("phase") in ("commentary", "final_answer")
194
190
  or (kind == "assistant_text" and event.get("phase") is None))
195
191
  and isinstance(event.get("text"), str) and event["text"].strip()
196
- and len(event["text"]) <= MAX_LINE_BYTES):
192
+ and len(event["text"]) <= MAX_TEXT_CHARS):
197
193
  self.native.append((event, _text_key(event["text"].strip())))
198
194
  if sum(map(len, (self.starts, self.ends, self.owners, self.native, self.candidates))) > MAX_TARGETS:
199
195
  raise _Unproven()
@@ -260,7 +256,7 @@ class _AssistantReplays:
260
256
  and block.get("type") == "text" and isinstance(block.get("text"), str))
261
257
  else:
262
258
  return
263
- if text.strip() and len(text) <= MAX_LINE_BYTES:
259
+ if text.strip() and len(text) <= MAX_TEXT_CHARS:
264
260
  digest = _text_key(text.strip())
265
261
  self.sources.setdefault((identity, digest), []).append(offset)
266
262
  self.source_display[(identity, digest)] = self.display_key(text)
@@ -440,7 +436,7 @@ class _AsyncDeliveryInputs:
440
436
  or any(event.get(key) for key in ("clientUserMessageId", "clientId", "client_user_message_id", "client_id"))):
441
437
  return
442
438
  full, display = self.normalize_full_user(event), self.normalize_user(event)
443
- match = _ASYNC_WRAPPER.fullmatch(full.strip()) if isinstance(full, str) and len(full) <= MAX_LINE_BYTES else None
439
+ match = _ASYNC_WRAPPER.fullmatch(full.strip()) if isinstance(full, str) and len(full) <= MAX_TEXT_CHARS else None
444
440
  timestamp = _timestamp(identity[2])
445
441
  if match and isinstance(display, str) and display and timestamp is not None:
446
442
  self.sources.setdefault((match[1], _text_key(match[2]), len(match[2]), int(timestamp)), []).append(
@@ -519,7 +515,7 @@ class _MailboxWakeInputs(_AsyncDeliveryInputs):
519
515
  return
520
516
  full, display = self.normalize_full_user(event), self.normalize_user(event)
521
517
  timestamp = _timestamp(identity[2])
522
- if (isinstance(full, str) and full and len(full) <= MAX_LINE_BYTES
518
+ if (isinstance(full, str) and full and len(full) <= MAX_TEXT_CHARS
523
519
  and _text_key(full) in self.wake_hashes
524
520
  and isinstance(display, str) and display and timestamp is not None):
525
521
  self.sources.setdefault(_text_key(full), []).append(
@@ -631,29 +627,18 @@ def filter_native_claude_mailbox_wake_items(
631
627
  "backend": "claude", "imported": True}
632
628
  native.event(candidate)
633
629
  assistant_targets[index] = _assistant_target(candidate)
634
- if source_stamp[2] <= MAX_EVENTS_BYTES:
635
- digest, verified = hashlib.sha256(), set()
636
- for event, offset, line in _records(source, source_stamp):
637
- digest.update(line)
638
- if offset == start and hmac.compare_digest(digest.hexdigest(), previous):
639
- verified.add(start)
640
- if offset == end and hmac.compare_digest(digest.hexdigest(), expected):
641
- verified.add(end)
642
- if offset <= end:
643
- proof.source(event, offset)
644
- native.source(event, offset)
645
- if end not in verified or start and start not in verified:
646
- return items
647
- else:
648
- # Same bounded proof as historical semantic windows: the caller has
649
- # just verified this checkpoint. Recheck file identity and every
650
- # candidate's exact source UUID/full hash, without a prefix rescan.
651
- window_start = max(0, end - MAX_EVENTS_BYTES)
652
- if start < window_start:
653
- return items
654
- for event, offset in _bounded_records(source, source_stamp, window_start, end):
630
+ digest, verified = hashlib.sha256(), set()
631
+ for event, offset, line in _records(source, source_stamp):
632
+ line.update_hash(digest)
633
+ if offset == start and hmac.compare_digest(digest.hexdigest(), previous):
634
+ verified.add(start)
635
+ if offset == end and hmac.compare_digest(digest.hexdigest(), expected):
636
+ verified.add(end)
637
+ if offset <= end:
655
638
  proof.source(event, offset)
656
639
  native.source(event, offset)
640
+ if end not in verified or start and start not in verified:
641
+ return items
657
642
  eligible = {batch: (0, len(items) + 1, start, end)}
658
643
  targets, assistant_replays = proof.prove(eligible), native.prove(eligible)
659
644
  _assistants, owned_inputs = native.exact_owned_sources()
@@ -923,7 +908,7 @@ def _prove(session_id: str, provider_id: str, events: Path, root: Path,
923
908
  raise _Unproven()
924
909
  source = source.resolve(strict=True)
925
910
  source.relative_to(root.resolve(strict=True))
926
- source_stamp = _stamp(source)
911
+ source_stamp = _regular_stamp(source)
927
912
  prefix_digests = {}
928
913
  eligible_batches = {}
929
914
  for run, (seq, checkpoint) in batches.items():
@@ -972,7 +957,7 @@ def _prove(session_id: str, provider_id: str, events: Path, root: Path,
972
957
  scheduled_ranges.setdefault(key, []).append((run, start_time, end_time))
973
958
  scheduled_sources, scheduled_counts = {}, {}
974
959
  for event, offset, line in _records(source, source_stamp):
975
- digest.update(line)
960
+ line.update_hash(digest)
976
961
  for wanted in prefix_digests.get(offset, ()):
977
962
  if hmac.compare_digest(digest.hexdigest(), wanted):
978
963
  verified.add((offset, wanted))
@@ -1144,25 +1129,25 @@ def _bounded_records(path: Path, expected, start: int, end: int):
1144
1129
  if (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns) != expected:
1145
1130
  raise _Unproven()
1146
1131
  stream.seek(start)
1132
+ deadline = time.monotonic() + SCAN_SECONDS
1147
1133
  if start:
1148
1134
  stream.seek(start - 1)
1149
1135
  if stream.read(1) != b"\n":
1150
- skipped = stream.readline(min(MAX_LINE_BYTES + 1, end - start))
1151
- if len(skipped) > MAX_LINE_BYTES or not skipped.endswith(b"\n"):
1136
+ while stream.tell() < end:
1137
+ scan_check(deadline)
1138
+ skipped = stream.readline(min(CHUNK_BYTES, end - stream.tell()))
1139
+ if not skipped:
1140
+ raise _Unproven()
1141
+ if skipped.endswith(b"\n"):
1142
+ break
1143
+ else:
1152
1144
  raise _Unproven()
1153
- count = 0
1154
- while stream.tell() < end:
1155
- offset = stream.tell()
1156
- line = stream.readline(min(MAX_LINE_BYTES + 1, end - offset))
1157
- if len(line) > MAX_LINE_BYTES or not line.endswith(b"\n"):
1158
- raise _Unproven()
1159
- count += 1
1160
- if count > MAX_RECORDS:
1161
- raise _Unproven()
1162
- event = json.loads(line)
1163
- if not isinstance(event, dict):
1164
- raise _Unproven()
1165
- yield event, stream.tell()
1145
+ try:
1146
+ for event, offset, _raw in jsonl_records(stream, end=end, strict=True,
1147
+ deadline=deadline, max_records=MAX_RECORDS, max_retained_bytes=MAX_LINE_BYTES):
1148
+ yield event, offset
1149
+ except ValueError as exc:
1150
+ raise _Unproven() from exc
1166
1151
  final = os.fstat(stream.fileno())
1167
1152
  if (final.st_dev, final.st_ino, final.st_size, final.st_mtime_ns) != expected:
1168
1153
  raise _Unproven()
@@ -22,9 +22,10 @@ MAX_API_KEY_CHARS = 4096
22
22
  AUTH_TIMEOUT_SECONDS = 30.0
23
23
  BUSY_MESSAGE = "Wait for Codex chats, goals, queued turns and Side chat requests to finish before changing authentication."
24
24
  HANDOFF_MESSAGE = (
25
- "Refreshing Codex sign-in. Wait for this chat's running work, approvals, "
26
- "goals and background terminals to finish, then retry. Your message has "
27
- "not been sent to Codex."
25
+ "Codex sign-in changed or was explicitly reloaded. This chat is waiting for "
26
+ "its running work, approvals, goals or background terminals before switching. "
27
+ "Your running work has not been stopped and this message has not been sent. "
28
+ "Retry after that work finishes."
28
29
  )
29
30
  _REVISION_KEY = secrets.token_bytes(32)
30
31
  _MAX_AUTH_BYTES = 128 * 1024
@@ -60,6 +61,54 @@ def handoff_detail(reason: str) -> dict:
60
61
  "message": f"Codex sign-in handoff is waiting: {reasons[reason]}. "
61
62
  f"Your message has not been sent. {action}"}
62
63
 
64
+ # Native ChatGPT sign-in only. API-key and custom-provider availability is
65
+ # provider-owned. Keep old IDs in saved conversations; this is picker/admission
66
+ # policy, not transcript cleanup. Source: https://learn.chatgpt.com/docs/models
67
+ CHATGPT_RETIRED_MODELS = frozenset({
68
+ "gpt-5.2", "gpt-5.2-codex", "gpt-5.3-codex", "gpt-5.3-codex-spark",
69
+ "gpt-5.4", "gpt-5.4-mini", "gpt-5.5",
70
+ })
71
+
72
+
73
+ def native_model_auth_mode(env: dict[str, str], *, cwd: str, observed_mode: str = "") -> str:
74
+ """Read only the auth kind for model policy, never authenticate or refresh.
75
+
76
+ An opaque store may use the existing native login-status probe. A file
77
+ store is checked directly so a switch to an API key cannot inherit a cached
78
+ ChatGPT restriction. Unknown/managed providers are never globally banned.
79
+ """
80
+ try:
81
+ root, config = _native_login_config(env, cwd)
82
+ profile = config.get("profile")
83
+ profiles = config.get("profiles")
84
+ selected_profile = profiles.get(profile, {}) if isinstance(profiles, dict) and isinstance(profile, str) else {}
85
+ if not isinstance(selected_profile, dict):
86
+ return "unknown"
87
+ provider = selected_profile.get("model_provider", config.get("model_provider", "openai"))
88
+ if provider != "openai":
89
+ return "custom"
90
+ providers = config.get("model_providers") or {}
91
+ override = providers.get("openai") if isinstance(providers, dict) else None
92
+ if env.get("OPENAI_BASE_URL") or isinstance(override, dict) and override.get("base_url"):
93
+ return "custom"
94
+ if any(env.get(key) for key in ("CODEX_API_KEY", "OPENAI_API_KEY", "OPENAI_IDENTITY_TOKEN_FILE")):
95
+ return "apiKey"
96
+ if env.get("CODEX_ACCESS_TOKEN"):
97
+ return "unknown"
98
+ store = config.get("cli_auth_credentials_store", "file")
99
+ if store != "file":
100
+ return observed_mode if store in {"keyring", "auto"} and observed_mode in {"chatgpt", "apiKey"} else "unknown"
101
+ data = json.loads(_bounded_file(root / "auth.json"))
102
+ if not isinstance(data, dict):
103
+ return "unknown"
104
+ if isinstance(data.get("OPENAI_API_KEY"), str) and data["OPENAI_API_KEY"]:
105
+ return "apiKey"
106
+ if data.get("auth_mode") in (None, "chatgpt") and isinstance(data.get("tokens"), dict):
107
+ return "chatgpt"
108
+ except (OSError, ValueError, TypeError, RecursionError):
109
+ pass
110
+ return "unknown"
111
+
63
112
 
64
113
  @dataclass(frozen=True)
65
114
  class LoginRevision:
@@ -115,8 +164,9 @@ def native_login_revision(env: dict[str, str], *, cwd: str) -> LoginRevision | N
115
164
 
116
165
  Ignore rotating access/refresh tokens and last_refresh. OIDC auth_time is
117
166
  stable across refresh but changes on an interactive authentication. Without
118
- it we can detect an account switch, not prove a same-account re-login;
119
- Recheck CLIs provides an explicit handoff for that case and keyring stores.
167
+ it we can detect an account switch, not prove a same-account re-login.
168
+ Explicit native-admin reload can hand off opaque/keyring logins; an
169
+ unchanged known revision remains observational even on explicit reload.
120
170
  Missing/malformed files are not evidence of a new login. No secret survives
121
171
  this bounded read except a process-keyed comparison digest.
122
172
  """
@@ -209,7 +259,7 @@ async def read_account(manager) -> dict:
209
259
  return account_summary(result)
210
260
 
211
261
 
212
- def create_router(*, authorize, operation, available) -> APIRouter:
262
+ def create_router(*, authorize, operation, available, reload_login=None) -> APIRouter:
213
263
  router = APIRouter()
214
264
 
215
265
  @router.get("/api/admin/codex/auth")
@@ -227,7 +277,26 @@ def create_router(*, authorize, operation, available) -> APIRouter:
227
277
  raise
228
278
  except Exception:
229
279
  raise HTTPException(503, "Codex authentication status is unavailable.") from None
230
- return JSONResponse(result, headers={"Cache-Control": "no-store"})
280
+ return JSONResponse({**result, "login_reload_available": reload_login is not None},
281
+ headers={"Cache-Control": "no-store"})
282
+
283
+ @router.post("/api/admin/codex/auth/reload")
284
+ async def reload(request: Request):
285
+ authorize(request)
286
+ if not available() or reload_login is None:
287
+ raise HTTPException(501, "Native Codex sign-in reload is unavailable.")
288
+ # No credential input; no handoff from GET, mounting a page or polling.
289
+ try:
290
+ body = await request.json()
291
+ except ValueError:
292
+ raise HTTPException(400, "Send an empty JSON object to reload Codex sign-in.") from None
293
+ if body != {}:
294
+ raise HTTPException(400, "Send an empty JSON object to reload Codex sign-in.")
295
+ pending = await reload_login()
296
+ response = await status(request)
297
+ result = json.loads(response.body)
298
+ return JSONResponse({**result, "login_reload_pending": pending},
299
+ headers={"Cache-Control": "no-store"})
231
300
 
232
301
  @router.post("/api/admin/codex/auth/api-key")
233
302
  async def login(request: Request):