@agentsdock/server 1.0.10-beta.6 → 1.0.11-beta.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentsdock/server",
3
- "version": "1.0.10-beta.6",
3
+ "version": "1.0.11-beta.1",
4
4
  "description": "AgentsDock's self-hosted Python server and explicit installation CLI",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/server/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.10-beta.6
1
+ 1.0.11-beta.1
@@ -26,6 +26,7 @@ import importlib.util
26
26
  import inspect
27
27
  import io
28
28
  import json
29
+ from transcript_reader import jsonl_records, scan_check, SCAN_SECONDS
29
30
  import logging
30
31
  import logging.handlers
31
32
  import math
@@ -471,39 +472,17 @@ def boolean_setting(suffix: str, default: bool) -> bool:
471
472
  return default
472
473
 
473
474
 
474
- CODEX_GOALS_DEFAULT_ENABLED = boolean_setting("CODEX_GOALS_ENABLED", True)
475
+ CODEX_GOALS_DEFAULT_ENABLED = True
475
476
 
476
477
 
477
478
  def read_codex_goals_enabled(path: Path | None = None) -> bool:
478
- """Read the persistent server-wide Codex goals policy.
479
+ """Goals are available without a server-wide opt-in.
479
480
 
480
- Existing installations have no settings file, so goals remain enabled by
481
- default. An environment setting can choose the first-run default, while
482
- an administrator's persisted choice remains authoritative afterward.
481
+ Ignore the retired environment/config switch, including persisted False
482
+ from older clients. Do not rewrite shared settings or resume paused goals.
483
+ Keep the optional path argument for compatibility with existing callers.
483
484
  """
484
-
485
- settings_path = path or CODEX_SETTINGS_FILE
486
- try:
487
- value = json.loads(settings_path.read_text())
488
- except FileNotFoundError:
489
- return CODEX_GOALS_DEFAULT_ENABLED
490
- except (json.JSONDecodeError, OSError) as exc:
491
- logger.warning(
492
- "could not read Codex server settings from %s: %s",
493
- settings_path,
494
- exc,
495
- )
496
- return CODEX_GOALS_DEFAULT_ENABLED
497
- if not isinstance(value, dict) or not isinstance(
498
- value.get("goals_enabled"),
499
- bool,
500
- ):
501
- logger.warning(
502
- "ignoring malformed Codex server settings at %s",
503
- settings_path,
504
- )
505
- return CODEX_GOALS_DEFAULT_ENABLED
506
- return bool(value["goals_enabled"])
485
+ return True
507
486
 
508
487
 
509
488
  # Per-thread Codex config overrides (codex-cli 0.149.1: thread/start,
@@ -1319,8 +1298,9 @@ HISTORY_SYNC_EVENT_SCAN_LIMIT = int(
1319
1298
  MAX_IMPORTED_TEXT_CHARS = int(agentsdock_setting("HISTORY_IMPORT_TEXT_CHARS", "12000"))
1320
1299
  MAX_LOCAL_SESSION_LIST_ITEMS = 500
1321
1300
  MAX_LOCAL_SESSION_SCAN_FILES = 10_000
1322
- MAX_LOCAL_TRANSCRIPT_BYTES = 256 * 1024 * 1024
1323
- MAX_LOCAL_TRANSCRIPT_LINE_BYTES = 4 * 1024 * 1024
1301
+ # Retained text/metadata budget. Opaque native media/tool bytes are streamed.
1302
+ # Private timeline/index readers still use this as their raw record bound.
1303
+ MAX_LOCAL_TRANSCRIPT_LINE_BYTES = 32 * 1024 * 1024
1324
1304
  MAX_LOCAL_TRANSCRIPT_SCAN_LINES = 250_000
1325
1305
  MAX_CODEX_SESSION_INDEX_BYTES = 16 * 1024 * 1024
1326
1306
  MAX_CODEX_SESSION_INDEX_LINES = 100_000
@@ -10706,6 +10686,8 @@ class SessionStore:
10706
10686
  "created_at": now,
10707
10687
  "updated_at": now,
10708
10688
  }
10689
+ if not active_provider_id and not parent_id and sess["title"] == "New chat":
10690
+ sess["_empty_chat_cleanup"] = {"version": 1, "dismissed_by": {}}
10709
10691
  if req.subagent_limit is not None:
10710
10692
  sess["subagent_limit"] = req.subagent_limit
10711
10693
  if initializing_fork and parent_id:
@@ -44941,98 +44923,44 @@ def bounded_jsonl_paths(
44941
44923
  continue
44942
44924
 
44943
44925
 
44944
- def bounded_jsonl_events(path: Path, *, preserve_invalid: bool = False) -> Iterator[dict[str, Any] | None]:
44945
- """Parse a transcript with hard byte, line-size, and line-count bounds."""
44946
44926
 
44947
- size = path.stat().st_size
44948
- if size > MAX_LOCAL_TRANSCRIPT_BYTES:
44949
- raise ValueError(
44950
- f"transcript exceeds the {MAX_LOCAL_TRANSCRIPT_BYTES}-byte import limit"
44951
- )
44952
- consumed = 0
44927
+
44928
+ def bounded_jsonl_events(path: Path, *, preserve_invalid: bool = False) -> Iterator[dict[str, Any] | None]:
44929
+ """Project native records with bounded memory, not a source-byte cap."""
44930
+ initial = path.stat()
44931
+ expected = {field: int(getattr(initial, field)) for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")}
44953
44932
  with path.open("rb") as stream:
44954
- for line_number in range(1, MAX_LOCAL_TRANSCRIPT_SCAN_LINES + 1):
44955
- raw_line = stream.readline(MAX_LOCAL_TRANSCRIPT_LINE_BYTES + 1)
44956
- if not raw_line:
44957
- return
44958
- consumed += len(raw_line)
44959
- if consumed > MAX_LOCAL_TRANSCRIPT_BYTES:
44960
- raise ValueError("transcript changed while it was being imported")
44961
- if len(raw_line) > MAX_LOCAL_TRANSCRIPT_LINE_BYTES:
44962
- raise ValueError(f"transcript line {line_number} exceeds the import limit")
44963
- if not raw_line.strip():
44964
- continue
44965
- try:
44966
- event = json.loads(raw_line)
44967
- except (json.JSONDecodeError, UnicodeDecodeError):
44968
- if preserve_invalid:
44969
- yield None
44970
- continue
44971
- if isinstance(event, dict):
44933
+ opened = os.fstat(stream.fileno())
44934
+ if any(int(getattr(opened, field)) != expected[field] for field in expected):
44935
+ raise ValueError("transcript changed before parsing")
44936
+ for event, _end, raw in jsonl_records(stream, end=initial.st_size,
44937
+ require_newline=False, max_records=MAX_LOCAL_TRANSCRIPT_SCAN_LINES,
44938
+ max_retained_bytes=MAX_LOCAL_TRANSCRIPT_LINE_BYTES):
44939
+ if not raw.blank and (event is not None or preserve_invalid):
44972
44940
  yield event
44973
- elif preserve_invalid:
44974
- yield None
44975
- if stream.read(1):
44976
- raise ValueError(
44977
- f"transcript exceeds the {MAX_LOCAL_TRANSCRIPT_SCAN_LINES}-line import limit"
44978
- )
44941
+ finished = os.fstat(stream.fileno())
44942
+ if any(int(getattr(finished, field)) != expected[field] for field in expected):
44943
+ raise ValueError("transcript changed during parsing")
44979
44944
 
44980
44945
 
44981
44946
  def bounded_jsonl_records_range(
44982
- path: Path,
44983
- start: int,
44984
- end: int,
44985
- *,
44986
- expected_stat: dict[str, int],
44947
+ path: Path, start: int, end: int, *, expected_stat: dict[str, int],
44948
+ require_newline: bool = True,
44987
44949
  ) -> Iterator[tuple[dict[str, Any] | None, int]]:
44988
44950
  """Read every record and ending offset in one immutable JSONL range."""
44989
-
44990
- if start < 0 or end < start or end > MAX_LOCAL_TRANSCRIPT_BYTES:
44951
+ if start < 0 or end < start or end > expected_stat["st_size"]:
44991
44952
  raise ValueError("transcript cursor range is invalid")
44992
- remaining = end - start
44993
- if remaining == 0:
44994
- return
44995
44953
  with path.open("rb") as stream:
44996
44954
  before = os.fstat(stream.fileno())
44997
- if any(
44998
- int(getattr(before, field)) != int(expected_stat[field])
44999
- for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")
45000
- ):
44955
+ if any(int(getattr(before, field)) != int(expected_stat[field]) for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")):
45001
44956
  raise ValueError("transcript changed before cursor parsing")
45002
44957
  stream.seek(start)
45003
- for line_number in range(1, MAX_LOCAL_TRANSCRIPT_SCAN_LINES + 1):
45004
- if remaining <= 0:
45005
- break
45006
- raw_line = stream.readline(
45007
- min(MAX_LOCAL_TRANSCRIPT_LINE_BYTES + 1, remaining)
45008
- )
45009
- if not raw_line:
45010
- raise ValueError("transcript was truncated during cursor parsing")
45011
- remaining -= len(raw_line)
45012
- if len(raw_line) > MAX_LOCAL_TRANSCRIPT_LINE_BYTES:
45013
- raise ValueError(
45014
- f"transcript cursor line {line_number} exceeds the import limit"
45015
- )
45016
- if not raw_line.endswith(b"\n"):
45017
- raise ValueError("transcript cursor does not end on a complete line")
45018
- event: dict[str, Any] | None = None
45019
- if raw_line.strip():
45020
- try:
45021
- loaded = json.loads(raw_line)
45022
- except (json.JSONDecodeError, UnicodeDecodeError):
45023
- loaded = None
45024
- if isinstance(loaded, dict):
45025
- event = loaded
45026
- yield event, int(stream.tell())
45027
- if remaining > 0:
45028
- raise ValueError(
45029
- f"transcript cursor range exceeds the {MAX_LOCAL_TRANSCRIPT_SCAN_LINES}-line import limit"
45030
- )
44958
+ for event, offset, _raw in jsonl_records(stream, end=end,
44959
+ require_newline=require_newline, max_records=MAX_LOCAL_TRANSCRIPT_SCAN_LINES,
44960
+ max_retained_bytes=MAX_LOCAL_TRANSCRIPT_LINE_BYTES):
44961
+ yield event, offset
45031
44962
  after = os.fstat(stream.fileno())
45032
- if any(
45033
- int(getattr(after, field)) != int(expected_stat[field])
45034
- for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")
45035
- ):
44963
+ if any(int(getattr(after, field)) != int(expected_stat[field]) for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")):
45036
44964
  raise ValueError("transcript changed during cursor parsing")
45037
44965
 
45038
44966
 
@@ -47494,7 +47422,6 @@ def normalized_history_sync_cursor(
47494
47422
  or int(raw.get(field, 0)) < 0
47495
47423
  for field in optional_integer_fields
47496
47424
  )
47497
- or int(raw["source_size"]) > MAX_LOCAL_TRANSCRIPT_BYTES
47498
47425
  or int(raw["source_offset"]) > int(raw["source_size"])
47499
47426
  or (
47500
47427
  timeline_pending_active
@@ -47675,10 +47602,6 @@ def provider_history_source_snapshot(
47675
47602
  """Hash a stable transcript through its last complete JSONL record."""
47676
47603
 
47677
47604
  initial = path.stat()
47678
- if initial.st_size > MAX_LOCAL_TRANSCRIPT_BYTES:
47679
- raise ValueError(
47680
- f"transcript exceeds the {MAX_LOCAL_TRANSCRIPT_BYTES}-byte import limit"
47681
- )
47682
47605
  # Provider CLIs may rotate a JSONL file with atomic replacement or move it
47683
47606
  # between dated directories. Backend/provider identity is validated before
47684
47607
  # this helper; byte-for-byte prefix continuity is authoritative across a
@@ -47693,7 +47616,8 @@ def provider_history_source_snapshot(
47693
47616
  prefix_digest = hashlib.sha256() if verify_offset is not None else None
47694
47617
  prefix_hashed = 0
47695
47618
  complete_size = 0
47696
- pending = b""
47619
+ running_digest = hashlib.sha256()
47620
+ deadline = time.monotonic() + SCAN_SECONDS
47697
47621
  with path.open("rb") as stream:
47698
47622
  opened = os.fstat(stream.fileno())
47699
47623
  expected_identity = (
@@ -47719,15 +47643,15 @@ def provider_history_source_snapshot(
47719
47643
  take = min(len(chunk), int(verify_offset) - prefix_hashed)
47720
47644
  prefix_digest.update(chunk[:take])
47721
47645
  prefix_hashed += take
47722
- pending += chunk
47723
- newline = pending.rfind(b"\n")
47646
+ scan_check(deadline, budget_seconds=SCAN_SECONDS)
47647
+ newline = chunk.rfind(b"\n")
47724
47648
  if newline >= 0:
47725
- complete = pending[:newline + 1]
47726
- complete_digest.update(complete)
47727
- complete_size += len(complete)
47728
- pending = pending[newline + 1:]
47729
- if len(pending) > MAX_LOCAL_TRANSCRIPT_LINE_BYTES:
47730
- raise ValueError("incomplete transcript line exceeds the import limit")
47649
+ running_digest.update(chunk[:newline + 1])
47650
+ complete_digest = running_digest.copy()
47651
+ complete_size = int(initial.st_size) - remaining - len(chunk) + newline + 1
47652
+ running_digest.update(chunk[newline + 1:])
47653
+ else:
47654
+ running_digest.update(chunk)
47731
47655
  finished = os.fstat(stream.fileno())
47732
47656
  if (
47733
47657
  int(finished.st_dev),
@@ -50280,6 +50204,9 @@ def public_session(sess: dict[str, Any], *, summary: bool = False) -> dict[str,
50280
50204
  # Provider ids are intentionally omitted from summary responses, but the
50281
50205
  # UI still needs the authoritative first-turn backend fence.
50282
50206
  public["backend_locked"] = session_backend_locked(sess)
50207
+ # Capability, not proof of emptiness: dismiss-empty repeats the full proof.
50208
+ if sess.get("title") == "New chat" and not public["backend_locked"]:
50209
+ public["empty_chat_cleanup"] = "client-v1"
50283
50210
  public["codex_provider"] = codex_provider.session_choice(sess.get("codex_provider"))
50284
50211
  connection = sess.get("provider_connection") or "default"
50285
50212
  # Keep legacy native summaries sparse, but retain a stored default as the
@@ -54177,26 +54104,29 @@ async def refresh_codex_app_server_binary(*, force: bool = False) -> None:
54177
54104
  schedule_codex_manager_drain()
54178
54105
 
54179
54106
 
54180
- async def refresh_codex_app_server_login(*, request_handoff: bool = False) -> None:
54107
+ async def refresh_codex_app_server_login(*, request_handoff: bool = False, source: str = "manager_lookup") -> bool:
54181
54108
  """Change normal-Codex admission; never kill a credential-owning process.
54182
54109
 
54183
- Catalog refresh only observes the native login revision. Only an explicit
54184
- provider reload requests handoff for opaque credential stores. Neither
54185
- operation logs in or rotates native credentials.
54110
+ Ordinary lookups/catalog refreshes only observe native file revisions.
54111
+ Explicit native-admin login reload also covers opaque credential stores.
54112
+ This is not a token refresh or login operation.
54186
54113
  """
54187
54114
  global CODEX_APP_SERVER_MANAGER, CODEX_LOGIN_REVISION
54188
54115
  if CODEX_TRANSPORT == CODEX_TRANSPORT_EXEC or CODEX_AUTH_LOCK.locked() or CODEX_MANAGER_CLOSING:
54189
- return
54116
+ return False
54117
+ # Sources are server-owned labels, never request text, account IDs or keys.
54118
+ if source not in {"manager_lookup", "turn_admission", "account_status", "catalog_refresh", "manual_login_reload"}:
54119
+ source = "manager_lookup"
54190
54120
  async with CODEX_LOGIN_PROBE_LOCK:
54191
54121
  if not await asyncio.to_thread(codex_auth.native_login_handoff_supported,
54192
54122
  codex_app_server_env(), cwd=existing_cwd(DEFAULT_CWD)):
54193
- return
54123
+ return False
54194
54124
  revision = await asyncio.to_thread(codex_auth.native_login_revision,
54195
54125
  codex_app_server_env(), cwd=existing_cwd(DEFAULT_CWD))
54196
54126
  async with CODEX_GOALS_CONFIG_LOCK:
54197
54127
  async with CODEX_APP_SERVER_MANAGER_LOCK:
54198
54128
  if CODEX_APP_SERVER_MANAGER_CLEANUP_EPOCH is not None or CODEX_AUTH_LOCK.locked() or CODEX_MANAGER_CLOSING:
54199
- return
54129
+ return False
54200
54130
  observed_change = revision is not None and revision != CODEX_LOGIN_REVISION
54201
54131
  CODEX_LOGIN_REVISION = revision
54202
54132
  if observed_change or request_handoff:
@@ -54212,12 +54142,17 @@ async def refresh_codex_app_server_login(*, request_handoff: bool = False) -> No
54212
54142
  if revision is not None and revision == previous:
54213
54143
  manager._agentsdock_login_superseded = False
54214
54144
  if changed or (request_handoff and revision is None):
54145
+ if getattr(manager, "_agentsdock_login_superseded", False) is not True:
54146
+ reason = "login_revision_changed" if changed else "explicit_reload_opaque_login"
54147
+ manager._agentsdock_login_handoff_source = source
54148
+ logger.info("Codex login handoff requested source=%s reason=%s manager=%x", source, reason, id(manager))
54215
54149
  manager._agentsdock_login_superseded = True
54216
54150
  if not any(manager is item for item in CODEX_RETIRED_APP_SERVER_MANAGERS):
54217
54151
  CODEX_RETIRED_APP_SERVER_MANAGERS.append(manager)
54218
54152
  if CODEX_APP_SERVER_MANAGER is manager:
54219
54153
  CODEX_APP_SERVER_MANAGER = None
54220
54154
  schedule_codex_manager_drain()
54155
+ return True
54221
54156
 
54222
54157
 
54223
54158
  def codex_manager_session_blocker(manager: CodexAppServerManager, session_id: str, *, ignore_task=None, ignore_maintenance=False) -> str | None:
@@ -54320,6 +54255,17 @@ def watch_codex_provider_handoff_blockers(
54320
54255
 
54321
54256
  async def release_idle_codex_manager_session(manager: CodexAppServerManager, session_id: str, *, ignore_task=None) -> bool:
54322
54257
  """Called under the session lifecycle lock; retain ownership on any doubt."""
54258
+ def retained(reason: str) -> bool:
54259
+ if getattr(manager, "_agentsdock_login_superseded", False) is True:
54260
+ reasons = getattr(manager, "_agentsdock_login_handoff_blockers", None)
54261
+ if reasons is None:
54262
+ reasons = manager._agentsdock_login_handoff_blockers = {}
54263
+ if reasons.get(session_id) != reason:
54264
+ reasons[session_id] = reason
54265
+ logger.info("Codex login handoff waiting source=%s manager=%x session=%s reason=%s",
54266
+ getattr(manager, "_agentsdock_login_handoff_source", "unknown"), id(manager), session_id, reason)
54267
+ return False
54268
+
54323
54269
  def blocked(*, own_maintenance=False):
54324
54270
  return (codex_manager_session_busy(manager, session_id, ignore_task=ignore_task,
54325
54271
  ignore_maintenance=own_maintenance)
@@ -54360,7 +54306,7 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
54360
54306
  sum(not task.done() for task in manager.client._server_request_tasks.values()),
54361
54307
  error_type,
54362
54308
  )
54363
- return False
54309
+ return retained(reasons[session_id])
54364
54310
 
54365
54311
  if blocked():
54366
54312
  return await defer("runtime_borrowers")
@@ -54440,6 +54386,10 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
54440
54386
  if CODEX_SESSION_APP_SERVER_MANAGERS.get(session_id) is manager:
54441
54387
  CODEX_SESSION_APP_SERVER_MANAGERS.pop(session_id, None)
54442
54388
  CODEX_GOAL_SYNC_GENERATIONS.pop(session_id, None)
54389
+ if getattr(manager, "_agentsdock_login_superseded", False) is True:
54390
+ getattr(manager, "_agentsdock_login_handoff_blockers", {}).pop(session_id, None)
54391
+ logger.info("Codex login handoff released source=%s manager=%x session=%s",
54392
+ getattr(manager, "_agentsdock_login_handoff_source", "unknown"), id(manager), session_id)
54443
54393
  return True
54444
54394
  except Exception as exc:
54445
54395
  logger.debug("Codex handoff retained an unverified idle owner", exc_info=True)
@@ -54459,7 +54409,7 @@ async def prepare_codex_login_turn(session: dict[str, Any]) -> None:
54459
54409
  session.pop("_codex_provider_pending", None)
54460
54410
  if codex_provider.session_choice(session.get("codex_provider")) == "custom":
54461
54411
  return
54462
- await refresh_codex_app_server_login()
54412
+ await refresh_codex_app_server_login(source="turn_admission")
54463
54413
  session_id = str(session.get("id") or "")
54464
54414
  manager = existing_codex_app_server_manager(session)
54465
54415
  if getattr(manager, "_agentsdock_login_superseded", False) is not True:
@@ -54618,9 +54568,8 @@ async def codex_app_server_manager(sess: dict[str, Any] | None = None, *, allow_
54618
54568
  selected = CODEX_PROVIDER_STORE.for_session(sess, include_key=True) if sess is not None else None
54619
54569
  revision = selected["credential_id"] if selected else None
54620
54570
  session_id = str((sess or {}).get("id") or "")
54621
- # Goal enablement is a process launch flag. Manager lookup therefore takes
54622
- # the same barrier as configuration replacement, including the fast path;
54623
- # no caller can retain/create the old generation halfway through a toggle.
54571
+ # Manager lookup shares the provider reconfiguration barrier, including the
54572
+ # fast path, so a sign-in reload cannot race a retained/created generation.
54624
54573
  async with CODEX_GOALS_CONFIG_LOCK:
54625
54574
  ensure_provider_manager_factory_admission(codex=True)
54626
54575
  manager = existing_codex_app_server_manager(sess) if sess is not None else None
@@ -78295,6 +78244,7 @@ async def require_agent_token(request: Request, call_next):
78295
78244
  codex_goals_admin_route = request.url.path in {
78296
78245
  "/api/admin/codex/goals", "/api/admin/codex/subagents",
78297
78246
  "/api/admin/codex/auth", "/api/admin/codex/auth/api-key",
78247
+ "/api/admin/codex/auth/reload",
78298
78248
  "/api/admin/codex/provider", "/api/admin/codex/provider/test",
78299
78249
  "/api/admin/codex/provider/models",
78300
78250
  "/api/admin/provider-connections/claude", "/api/admin/provider-connections/claude/check",
@@ -78483,7 +78433,7 @@ async def require_agent_token(request: Request, call_next):
78483
78433
  if body_error is not None:
78484
78434
  status_code, detail = body_error
78485
78435
  return JSONResponse({"detail": detail}, status_code=status_code)
78486
- elif request.url.path == "/api/admin/codex/auth/api-key" and request.method.upper() == "POST":
78436
+ elif request.url.path in {"/api/admin/codex/auth/api-key", "/api/admin/codex/auth/reload"} and request.method.upper() == "POST":
78487
78437
  declared_size, transport_error = privileged_native_json_transport(
78488
78438
  request, max_body_bytes=codex_auth.MAX_BODY_BYTES,
78489
78439
  label="Codex authentication", require_content_length=True,
@@ -81302,15 +81252,11 @@ def codex_goals_admin_status() -> dict[str, Any]:
81302
81252
  enabled = bool(CODEX_GOALS_ENABLED)
81303
81253
  return {
81304
81254
  "enabled": enabled,
81305
- "configurable": True,
81255
+ "configurable": False,
81306
81256
  "default_enabled": bool(CODEX_GOALS_DEFAULT_ENABLED),
81307
81257
  "scope": "server",
81308
81258
  "reconfiguring": bool(CODEX_GOALS_RECONFIGURING),
81309
- "message": (
81310
- "Persistent Codex goals are enabled for this server."
81311
- if enabled
81312
- else "Persistent Codex goals are disabled for this server."
81313
- ),
81259
+ "message": "Persistent Codex goals are always available. Manage each goal in its chat.",
81314
81260
  }
81315
81261
 
81316
81262
 
@@ -82134,36 +82080,14 @@ async def get_codex_goals_admin() -> dict[str, Any]:
82134
82080
  async def put_codex_goals_admin(
82135
82081
  req: CodexGoalsAdminRequest,
82136
82082
  ) -> dict[str, Any]:
82137
- global CODEX_GOALS_ENABLED
82138
- async with CODEX_GOALS_CONFIG_LOCK:
82139
- requested = bool(req.enabled)
82140
- if requested == CODEX_GOALS_ENABLED:
82141
- return codex_goals_admin_status()
82142
- settings = read_codex_admin_settings()
82143
- await reserve_codex_goals_reconfiguration()
82144
- transition: dict[str, int] = {
82145
- "paused_goal_count": 0,
82146
- "fenced_goal_count": 0,
82147
- }
82148
- try:
82149
- if not requested:
82150
- transition = await pause_idle_codex_goals_before_disable()
82151
- # The feature flag is a process-level Codex setting. Replacing the
82152
- # shared, idle manager is the only way to make it authoritative for
82153
- # every loaded and future thread.
82154
- await close_codex_app_server_manager()
82155
- atomic_update_json(
82156
- CODEX_SETTINGS_FILE,
82157
- {
82158
- **settings,
82159
- "goals_enabled": requested,
82160
- "updated_at": update_utc_now(),
82161
- },
82162
- )
82163
- CODEX_GOALS_ENABLED = requested
82164
- finally:
82165
- await release_codex_goals_reconfiguration()
82166
- return {**codex_goals_admin_status(), **transition}
82083
+ # Preserve the authenticated legacy route without allowing an older client
82084
+ # to disable goals for every chat or restart the shared provider manager.
82085
+ if not req.enabled:
82086
+ raise HTTPException(
82087
+ status_code=409,
82088
+ detail="Persistent Codex goals cannot be disabled server-wide. Pause individual goals in their chats.",
82089
+ )
82090
+ return codex_goals_admin_status()
82167
82091
 
82168
82092
 
82169
82093
  def require_native_admin_control(request: Request) -> None:
@@ -82187,7 +82111,7 @@ def require_native_admin_control(request: Request) -> None:
82187
82111
  async def codex_auth_operation(*, mutate: bool, existing_only: bool = False):
82188
82112
  """Fence native authentication changes with the existing Codex admission barrier."""
82189
82113
  if not mutate:
82190
- await refresh_codex_app_server_login()
82114
+ await refresh_codex_app_server_login(source="account_status")
82191
82115
  async with CODEX_AUTH_LOCK:
82192
82116
  reserved = False
82193
82117
  try:
@@ -82224,10 +82148,20 @@ async def codex_auth_operation(*, mutate: bool, existing_only: bool = False):
82224
82148
  await release_codex_goals_reconfiguration()
82225
82149
 
82226
82150
 
82151
+ async def reload_codex_login() -> bool:
82152
+ logger.info("Codex login reload requested source=manual_login_reload")
82153
+ if not await refresh_codex_app_server_login(request_handoff=True, source="manual_login_reload"):
82154
+ logger.info("Codex login reload deferred source=manual_login_reload reason=unavailable_or_busy")
82155
+ raise HTTPException(409, "Codex sign-in cannot be reloaded now. Its credential store is unsupported or maintenance is active.")
82156
+ return any(getattr(manager, "_agentsdock_login_superseded", False) is True
82157
+ for manager in codex_app_server_managers())
82158
+
82159
+
82227
82160
  app.include_router(codex_auth.create_router(
82228
82161
  authorize=require_native_admin_control,
82229
82162
  operation=codex_auth_operation,
82230
82163
  available=lambda: CODEX_TRANSPORT != CODEX_TRANSPORT_EXEC,
82164
+ reload_login=reload_codex_login,
82231
82165
  ))
82232
82166
 
82233
82167
 
@@ -85787,7 +85721,7 @@ async def host_diagnostics(limit: int = 40) -> dict[str, Any]:
85787
85721
  async def runtime_catalog(refresh: bool = False) -> dict[str, Any]:
85788
85722
  if refresh:
85789
85723
  await refresh_codex_app_server_binary(force=True)
85790
- await refresh_codex_app_server_login()
85724
+ await refresh_codex_app_server_login(source="catalog_refresh")
85791
85725
  return await asyncio.to_thread(discover_runtime_catalog, force_runtime_probe=refresh)
85792
85726
 
85793
85727
 
@@ -85908,12 +85842,13 @@ async def tail_session_process_log(session_id: str, path: str, lines: int = 200)
85908
85842
 
85909
85843
 
85910
85844
  @app.get("/api/sessions")
85911
- async def list_sessions(summary: bool = False) -> dict[str, Any]:
85845
+ async def list_sessions(summary: bool = False, placeholder_client_id: str = "") -> dict[str, Any]:
85912
85846
  await STORE.ensure_sort_orders()
85913
85847
  sessions = [
85914
85848
  public_session(session, summary=summary)
85915
85849
  for session in sorted_sessions(list(STORE.sessions.values()))
85916
85850
  if not session.get("_fork_initializing")
85851
+ and not empty_chat_dismissed_for(session, placeholder_client_id)
85917
85852
  ]
85918
85853
  return {"sessions": sessions}
85919
85854
 
@@ -89520,6 +89455,74 @@ async def ensure_discardable_empty_session(session_id: str, expected_updated_at:
89520
89455
  raise HTTPException(status_code=409, detail="Chat has content or resources; it was preserved.")
89521
89456
 
89522
89457
 
89458
+ def empty_chat_revision(session: dict[str, Any]) -> str:
89459
+ # Use durable content/settings, not public runtime projections, read cursors
89460
+ # or sort order. Reconnecting, reading or normalizing runtime metadata on
89461
+ # startup must not resurrect an unchanged blank row.
89462
+ revision = {key: session.get(key) for key in (
89463
+ "created_at", "updated_at", "title", "folder", "cwd", "backend",
89464
+ "model", "effort", "system_prompt", "_title_source",
89465
+ "codex_provider", "provider_connection", "parent_id",
89466
+ "codex_goal", "claude_goal",
89467
+ )}
89468
+ revision["latest_event_seq"] = int(session.get("latest_event_seq") or 0)
89469
+ for key in ("backend_locked", "pinned", "archived", "manual_unread"):
89470
+ revision[key] = bool(session.get(key))
89471
+ return hashlib.sha256(json.dumps(revision, sort_keys=True,
89472
+ separators=(",", ":")).encode()).hexdigest()
89473
+
89474
+
89475
+ def empty_chat_dismissed_for(session: dict[str, Any], client_id: str) -> bool:
89476
+ if not client_id or session_backend_locked(session):
89477
+ return False
89478
+ state = session.get("_empty_chat_cleanup") or {}
89479
+ return (isinstance(state, dict) and state.get("version") == 1
89480
+ and isinstance(state.get("dismissed_by"), dict)
89481
+ and state["dismissed_by"].get(client_id) == empty_chat_revision(session))
89482
+
89483
+
89484
+ @app.post("/api/sessions/{session_id}/dismiss-empty")
89485
+ async def dismiss_empty_session(session_id: str, expected_updated_at: str,
89486
+ placeholder_client_id: str) -> dict[str, Any]:
89487
+ """Remove an unused row from one desktop's list without deleting its identity.
89488
+
89489
+ Another/offline desktop may own an unsent draft. Its list is unaffected;
89490
+ subsequent real changes also invalidate this desktop's dismissal. No draft
89491
+ content leaves a desktop, and no native history/process is touched.
89492
+ """
89493
+ if not re.fullmatch(r"[a-zA-Z0-9_-]{16,80}", placeholder_client_id):
89494
+ raise HTTPException(400, "Invalid placeholder client identity")
89495
+ await wait_for_queue_recovery_admission()
89496
+ async with session_lifecycle_lock(session_id):
89497
+ ensure_session_not_initializing(session_id)
89498
+ if session_id in DELETING_SESSIONS or session_id in DELETED_SESSION_TOMBSTONES:
89499
+ raise HTTPException(409, "Chat deletion is already in progress")
89500
+ # Existing admission, uploads, jobs and terminal routes use this fence.
89501
+ DELETING_SESSIONS.add(session_id)
89502
+ try:
89503
+ await ensure_discardable_empty_session(session_id, expected_updated_at)
89504
+ async with STORE._lock:
89505
+ session = STORE.sessions[session_id]
89506
+ previous = session.get("_empty_chat_cleanup")
89507
+ state = previous if isinstance(previous, dict) else {}
89508
+ dismissed = dict(state.get("dismissed_by") or {})
89509
+ if placeholder_client_id not in dismissed and len(dismissed) >= 256:
89510
+ raise HTTPException(409, "Too many desktop cleanup identities; chat preserved")
89511
+ dismissed[placeholder_client_id] = empty_chat_revision(session)
89512
+ session["_empty_chat_cleanup"] = {"version": 1, "dismissed_by": dismissed}
89513
+ try:
89514
+ await STORE.save()
89515
+ except BaseException:
89516
+ if previous is None:
89517
+ session.pop("_empty_chat_cleanup", None)
89518
+ else:
89519
+ session["_empty_chat_cleanup"] = previous
89520
+ raise
89521
+ return {"ok": True, "dismissed": True}
89522
+ finally:
89523
+ DELETING_SESSIONS.discard(session_id)
89524
+
89525
+
89523
89526
  @app.post("/api/sessions/{session_id}/discard-empty")
89524
89527
  async def discard_empty_session(session_id: str, expected_updated_at: str) -> dict[str, Any]:
89525
89528
  # A separate route makes old servers fail safely with 404, rather than
@@ -90024,19 +90027,20 @@ def claude_imported_fork_boundary(
90024
90027
  digest = hashlib.sha256()
90025
90028
  last_message: dict[str, Any] = {}
90026
90029
  with path.open("rb") as stream:
90027
- for _line in range(MAX_LOCAL_TRANSCRIPT_SCAN_LINES):
90028
- if not remaining:
90029
- break
90030
- raw = stream.readline(min(MAX_LOCAL_TRANSCRIPT_LINE_BYTES + 1, remaining))
90031
- if not raw or not raw.endswith(b"\n") or len(raw) > MAX_LOCAL_TRANSCRIPT_LINE_BYTES:
90032
- raise ValueError("imported history checkpoint has an incomplete transcript record")
90033
- remaining -= len(raw)
90034
- digest.update(raw)
90035
- if not raw.strip():
90030
+ original = os.fstat(stream.fileno())
90031
+ for record, offset, raw in jsonl_records(stream, end=remaining,
90032
+ max_records=MAX_LOCAL_TRANSCRIPT_SCAN_LINES):
90033
+ raw.update_hash(digest)
90034
+ remaining = int(cursor["source_offset"]) - offset
90035
+ if raw.blank:
90036
90036
  continue
90037
- record = json.loads(raw)
90038
- if isinstance(record, dict) and record.get("type") in {"user", "assistant"} and not record.get("isSidechain"):
90037
+ if record is None:
90038
+ raise ValueError("imported history checkpoint has a malformed transcript record")
90039
+ if record.get("type") in {"user", "assistant"} and not record.get("isSidechain"):
90039
90040
  last_message = record
90041
+ current = os.fstat(stream.fileno())
90042
+ if (original.st_dev, original.st_ino, original.st_size, original.st_mtime_ns) != (current.st_dev, current.st_ino, current.st_size, current.st_mtime_ns):
90043
+ raise ValueError("imported history checkpoint changed during reading")
90040
90044
  if remaining or not hmac.compare_digest(digest.hexdigest(), cursor["source_digest"]):
90041
90045
  raise ValueError("imported history checkpoint no longer matches its native prefix")
90042
90046
  message = last_message.get("message")