@agentsdock/server 1.0.7-beta.5

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 (113) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +19 -0
  3. package/README.md +60 -0
  4. package/npm/cli.cjs +289 -0
  5. package/package.json +32 -0
  6. package/server/LICENSE +202 -0
  7. package/server/NOTICE +19 -0
  8. package/server/VERSION +1 -0
  9. package/server/activation_transaction.py +2458 -0
  10. package/server/agent_server.py +93226 -0
  11. package/server/agentsdock_chats.py +1237 -0
  12. package/server/agentsdock_emergency.py +338 -0
  13. package/server/agentsdock_jobs.py +640 -0
  14. package/server/agentsdock_mail.py +325 -0
  15. package/server/agentsdock_publish.py +404 -0
  16. package/server/agentsdock_team.py +793 -0
  17. package/server/agentsdock_team_hub/__init__.py +33 -0
  18. package/server/agentsdock_team_hub/auth.py +710 -0
  19. package/server/agentsdock_team_hub/cli.py +647 -0
  20. package/server/agentsdock_team_hub/database.py +310 -0
  21. package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
  22. package/server/agentsdock_team_hub/mail_hints.py +201 -0
  23. package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
  24. package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
  25. package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
  26. package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
  27. package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
  28. package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
  29. package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
  30. package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
  31. package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
  32. package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
  33. package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
  34. package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
  35. package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
  36. package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
  37. package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
  38. package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
  39. package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
  40. package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
  41. package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
  42. package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
  43. package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
  44. package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
  45. package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
  46. package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
  47. package/server/agentsdock_team_hub/notification_hints.py +306 -0
  48. package/server/agentsdock_team_hub/secure_peer.py +11215 -0
  49. package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
  50. package/server/agentsdock_team_hub/security.py +318 -0
  51. package/server/agentsdock_team_hub/service.py +1737 -0
  52. package/server/agentsdock_team_hub/store.py +16276 -0
  53. package/server/chat_mailbox.py +448 -0
  54. package/server/claude_background_reconciliation.py +92 -0
  55. package/server/claude_goals.py +245 -0
  56. package/server/claude_history_provenance.py +137 -0
  57. package/server/claude_history_repair.py +1601 -0
  58. package/server/claude_sdk_client.py +3731 -0
  59. package/server/claude_side_question.py +212 -0
  60. package/server/codex_app_server.py +3030 -0
  61. package/server/codex_auth.py +93 -0
  62. package/server/codex_history_repair.py +1247 -0
  63. package/server/codex_provider.py +1120 -0
  64. package/server/codex_side_question.py +301 -0
  65. package/server/cursor_agent_client.py +654 -0
  66. package/server/cursor_process_guard.py +347 -0
  67. package/server/execution_activation.py +610 -0
  68. package/server/execution_control.py +233 -0
  69. package/server/execution_durability.py +184 -0
  70. package/server/execution_http.py +256 -0
  71. package/server/execution_install.py +792 -0
  72. package/server/execution_legacy_runner.py +406 -0
  73. package/server/execution_maintenance.py +137 -0
  74. package/server/execution_manage.py +615 -0
  75. package/server/execution_ownership.py +83 -0
  76. package/server/execution_preparation.py +362 -0
  77. package/server/execution_recovery.py +845 -0
  78. package/server/execution_recovery_status.py +195 -0
  79. package/server/execution_service.py +387 -0
  80. package/server/execution_transport.py +622 -0
  81. package/server/execution_uninstall.py +406 -0
  82. package/server/execution_update_status.py +49 -0
  83. package/server/install.sh +7074 -0
  84. package/server/interactive_chat_controls.py +153 -0
  85. package/server/interactive_chat_native.py +66 -0
  86. package/server/interactive_chat_projection.py +152 -0
  87. package/server/interactive_chat_runtime.py +132 -0
  88. package/server/interactive_chat_share_routes.py +534 -0
  89. package/server/interactive_chat_share_web.py +12 -0
  90. package/server/interactive_chat_shares.py +238 -0
  91. package/server/provider_commands.py +425 -0
  92. package/server/public_chat_share_routes.py +426 -0
  93. package/server/public_chat_shares.py +759 -0
  94. package/server/public_chat_transcript.py +357 -0
  95. package/server/pyproject.toml +29 -0
  96. package/server/release-public-key.pem +3 -0
  97. package/server/secure_peer_delivery.py +1236 -0
  98. package/server/secure_peer_runtime.py +7694 -0
  99. package/server/shared_chat_video_stream.py +262 -0
  100. package/server/shared_chat_videos.py +155 -0
  101. package/server/side_questions.py +594 -0
  102. package/server/team_hub_host.py +1354 -0
  103. package/server/team_mail_grants.py +136 -0
  104. package/server/team_mail_runtime.py +302 -0
  105. package/server/team_mail_websocket.py +211 -0
  106. package/server/title_generation.py +333 -0
  107. package/server/uninstall.sh +359 -0
  108. package/server/update_handoff.py +237 -0
  109. package/server/update_preparation.py +264 -0
  110. package/server/update_recovery.py +140 -0
  111. package/server/update_runner.py +1819 -0
  112. package/server/uv.lock +1101 -0
  113. package/server/workspace_git.py +545 -0
@@ -0,0 +1,153 @@
1
+ """Explicit one-chat controls; no server imports, HTTP proxy, or authority lookup.
2
+
3
+ The router authenticates the share and supplies its chat identity. The owner
4
+ injects native models/callbacks and projects their results for the guest. Native
5
+ exceptions are deliberately not converted to safe denials: a callback may have
6
+ already committed work. Only ChatControlError is known to precede invocation.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from types import MappingProxyType
13
+
14
+
15
+ SETTINGS_FIELDS = frozenset({
16
+ "title", "backend", "model", "effort", "system_prompt",
17
+ "claude_permission_mode", "codex_approval_policy", "codex_sandbox_mode",
18
+ "codex_permission_profile", "codex_approvals_reviewer", "cursor_permission_mode",
19
+ "provider_jobs_access",
20
+ })
21
+ GOAL_FIELDS = frozenset({"objective", "status", "token_budget", "time_budget_seconds"})
22
+ JOB_FIELDS = frozenset({
23
+ "title", "prompt", "schedule_kind", "interval_seconds", "cron_expression",
24
+ "rrule", "timezone", "loop", "max_runs", "enabled", "backend",
25
+ })
26
+ ACTIONS = frozenset({
27
+ "state", "turn.stop", "turn.steer", "queue.run_now", "queue.edit", "queue.delete",
28
+ "queue.move", "settings.update", "goal.set", "goal.resume", "goal.pause", "goal.delete",
29
+ "job.create", "job.update", "job.delete", "job.toggle", "job.run", "approval.respond",
30
+ })
31
+
32
+
33
+ class ChatControlError(ValueError):
34
+ """A bounded rejection before any native callback or mutation."""
35
+
36
+ def __init__(self, code="invalid_request"):
37
+ self.code = code
38
+ super().__init__("This chat control request is not permitted" if code == "forbidden"
39
+ else "Invalid chat control request")
40
+
41
+
42
+ def _identity(value):
43
+ if not isinstance(value, str) or re.fullmatch(r"[A-Za-z0-9_.-]{1,128}", value) is None:
44
+ raise ChatControlError()
45
+ return value
46
+
47
+
48
+ def _fields(payload, allowed=(), required=(), *, nonempty=False):
49
+ if set(payload) - set(allowed) or not set(required).issubset(payload) or nonempty and not payload:
50
+ raise ChatControlError()
51
+
52
+
53
+ class InteractiveChatControls:
54
+ """Dispatch to fixed native functions with a server-bound first argument.
55
+
56
+ Callback keys normally equal action names; approval uses approval.codex or
57
+ approval.claude. Native models are keyed by their class names. turn.steer is
58
+ the owner's enqueue/exact-promotion wrapper; job.run is the owner's atomic
59
+ expected-session wrapper, never the global unscoped run_job endpoint.
60
+ """
61
+
62
+ def __init__(self, callbacks, models):
63
+ self.callbacks = MappingProxyType(dict(callbacks))
64
+ self.models = MappingProxyType(dict(models))
65
+
66
+ def _model(self, name, fields):
67
+ model = self.models.get(name)
68
+ if model is None:
69
+ raise ChatControlError("forbidden")
70
+ try:
71
+ return model.model_validate(fields)
72
+ except ValueError:
73
+ # Never expose native validation input, paths or object reprs.
74
+ raise ChatControlError() from None
75
+
76
+ async def dispatch(self, session_id, action, payload, *, share_id=None, request_id=None):
77
+ _identity(session_id)
78
+ if not isinstance(action, str) or action not in ACTIONS:
79
+ raise ChatControlError("forbidden")
80
+ if not isinstance(payload, dict) or any(not isinstance(key, str) for key in payload):
81
+ raise ChatControlError()
82
+ try:
83
+ encoded = json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8")
84
+ except (TypeError, ValueError, UnicodeError, RecursionError):
85
+ raise ChatControlError() from None
86
+ if len(encoded) > 64 * 1024:
87
+ raise ChatControlError()
88
+ payload = json.loads(encoded) # Callback sees an owned immutable-in-transit value.
89
+ callback_key, args, kwargs = action, [session_id], {}
90
+
91
+ if action in {"state", "turn.stop", "goal.delete", "goal.resume", "goal.pause"}:
92
+ _fields(payload)
93
+ if action in {"goal.resume", "goal.pause"}:
94
+ args.append(self._model("CodexGoalRequest", {"status": "active" if action == "goal.resume" else "paused"}))
95
+ elif action == "turn.steer":
96
+ _fields(payload, {"prompt"}, {"prompt"})
97
+ if not isinstance(payload["prompt"], str) or not payload["prompt"].strip():
98
+ raise ChatControlError()
99
+ if not isinstance(share_id, str) or re.fullmatch(r"interactive_[a-f0-9]{32}", share_id) is None:
100
+ raise ChatControlError("forbidden")
101
+ if not isinstance(request_id, str) or re.fullmatch(r"[A-Za-z0-9_-]{16,128}", request_id) is None:
102
+ raise ChatControlError()
103
+ args.append(payload["prompt"])
104
+ kwargs = {"share_id": share_id, "request_id": request_id}
105
+ elif action in {"queue.delete", "queue.run_now", "job.delete", "job.run"}:
106
+ _fields(payload, {"id"}, {"id"})
107
+ args.append(_identity(payload["id"]))
108
+ if action == "queue.run_now":
109
+ args.append(self._model("RunQueuedTurnNowRequest", {"accept_deferred_queue_response": True}))
110
+ elif action == "queue.edit":
111
+ _fields(payload, {"id", "prompt", "expected_message_revision"}, {"id", "prompt"})
112
+ args.append(_identity(payload.pop("id")))
113
+ if not isinstance(payload["prompt"], str):
114
+ raise ChatControlError()
115
+ args.append(self._model("UpdateQueuedTurnRequest", payload))
116
+ elif action == "queue.move":
117
+ _fields(payload, {"id", "direction", "expected_adjacent_queued_id"}, {"id", "direction"})
118
+ args.append(_identity(payload.pop("id")))
119
+ if not isinstance(payload["direction"], str) or payload["direction"] not in {"up", "down"}:
120
+ raise ChatControlError()
121
+ if "expected_adjacent_queued_id" in payload:
122
+ _identity(payload["expected_adjacent_queued_id"])
123
+ args.append(self._model("MoveQueuedTurnRequest", payload))
124
+ elif action == "settings.update":
125
+ _fields(payload, SETTINGS_FIELDS, nonempty=True)
126
+ args.append(self._model("UpdateSessionRequest", payload))
127
+ elif action == "goal.set":
128
+ _fields(payload, GOAL_FIELDS, nonempty=True)
129
+ args.append(self._model("CodexGoalRequest", payload))
130
+ elif action == "job.create":
131
+ _fields(payload, JOB_FIELDS | {"first_run_at"}, {"title", "prompt"})
132
+ args.append(self._model("CreateScopedJobRequest", {**payload, "context_mode": "chat"}))
133
+ elif action in {"job.update", "job.toggle"}:
134
+ allowed = {"id", "enabled"} if action == "job.toggle" else JOB_FIELDS | {"id", "next_run_at"}
135
+ _fields(payload, allowed, {"id", "enabled"} if action == "job.toggle" else {"id"})
136
+ args.append(_identity(payload.pop("id")))
137
+ if not payload or action == "job.toggle" and type(payload["enabled"]) is not bool:
138
+ raise ChatControlError()
139
+ args.append(self._model("UpdateJobRequest", payload))
140
+ elif action == "approval.respond":
141
+ _fields(payload, {"id", "backend", "response"}, {"id", "backend", "response"})
142
+ backend = payload["backend"]
143
+ if backend not in ("codex", "claude") or not isinstance(payload["response"], dict):
144
+ raise ChatControlError()
145
+ args.append(_identity(payload["id"]))
146
+ args.append(self._model("CodexInteractionResponseRequest" if backend == "codex" else "ClaudeInteractionResponseRequest",
147
+ {"response": payload["response"]}))
148
+ callback_key = "approval." + backend
149
+
150
+ callback = self.callbacks.get(callback_key)
151
+ if not callable(callback):
152
+ raise ChatControlError("forbidden")
153
+ return await callback(*args, **kwargs)
@@ -0,0 +1,66 @@
1
+ """Shared-chat DTOs reuse native UI contracts without file or server authority."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ from shared_chat_videos import normalize_shared_chat_videos
6
+
7
+ PRIVATE_FIELDS = frozenset({
8
+ "cwd", "path", "source_path", "root", "file", "files", "artifact", "artifacts",
9
+ "file_ids", "display_file_ids", "file_path", "notebook_path", "old_path", "new_path",
10
+ "attachments", "env", "environment", "runtime_env", "runtime_context", "authorization", "headers",
11
+ "token", "access_token", "refresh_token", "api_key", "password", "secret",
12
+ "authority", "authority_file", "authority_path", "request_prompt", "provider_cross_chat_route_snapshot",
13
+ "secure_peer_route_snapshots", "chat_references", "team_references",
14
+ "claude_session_id", "codex_thread_id", "cursor_session_id",
15
+ })
16
+ SESSION_FIELDS = frozenset({
17
+ "id", "title", "backend", "model", "effort", "system_prompt", "backend_locked",
18
+ "claude_permission_mode", "codex_approval_policy", "codex_sandbox_mode",
19
+ "codex_permission_profile", "codex_approvals_reviewer", "cursor_permission_mode",
20
+ "provider_jobs_access", "codex_goal", "codex_goal_time_budget_seconds",
21
+ "codex_goal_time_budget_exhausted", "codex_thread_status",
22
+ "codex_pending_interaction_count", "codex_needs_user_action",
23
+ "claude_pending_interaction_count", "claude_needs_user_action",
24
+ "claude_stop_fence_pending", "created_at", "updated_at", "archived",
25
+ "latest_event_seq", "latest_event_at", "latest_agent_event_seq", "latest_agent_event_at",
26
+ })
27
+
28
+
29
+ def shared_native_value(value, depth=0):
30
+ """Remove transport/file metadata, not words deliberately present in chat text.
31
+
32
+ This is an egress filter, never an authorization decision. Every callback
33
+ has already selected a single chat and used the native ownership checks.
34
+ """
35
+ if depth > 20:
36
+ raise ValueError("Shared chat data is too deeply nested")
37
+ if isinstance(value, dict):
38
+ return {key: shared_native_value(item, depth + 1) for key, item in value.items()
39
+ if isinstance(key, str) and not key.startswith("_")
40
+ and key.lower() not in PRIVATE_FIELDS}
41
+ if isinstance(value, (list, tuple)):
42
+ return [shared_native_value(item, depth + 1) for item in value]
43
+ if type(value) is float and not math.isfinite(value):
44
+ raise ValueError("Shared chat data contains an invalid number")
45
+ if value is None or type(value) in (str, int, float, bool):
46
+ return value
47
+ raise ValueError("Shared chat data is not serializable")
48
+
49
+
50
+ def shared_session(value):
51
+ return shared_native_value({key: item for key, item in value.items() if key in SESSION_FIELDS})
52
+
53
+
54
+ def shared_events(events, session_id):
55
+ output = []
56
+ for event in events:
57
+ if not isinstance(event, dict) or event.get("session_id") not in (None, "", session_id):
58
+ raise ValueError("Shared chat event ownership is invalid")
59
+ kind = str(event.get("type") or "")
60
+ # Only explicitly projected video descriptors cross the media boundary.
61
+ # Tool output is chat content, not direct filesystem access.
62
+ videos = normalize_shared_chat_videos(event.get("shared_videos", []))
63
+ if kind.startswith(("file_", "artifact_", "workspace_", "terminal_")) and not (kind == "artifact_created" and len(videos) == 1):
64
+ continue
65
+ output.append(shared_native_value(event))
66
+ return output
@@ -0,0 +1,152 @@
1
+ """Incremental, bounded text-only chat projection; no server imports or timers."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import stat
8
+ import threading
9
+ from typing import Callable
10
+
11
+ from public_chat_transcript import (
12
+ MAX_LINE_BYTES, MAX_LOG_BYTES, MAX_MESSAGE_BYTES, MAX_MESSAGES,
13
+ MAX_RECORDS, MAX_TEXT_BYTES, PublicTranscriptError,
14
+ _is_goal_followup, _public_timestamp,
15
+ )
16
+
17
+
18
+ class IncrementalChatTranscript:
19
+ """Use one fresh privacy projector for the lifetime of an append-only log.
20
+
21
+ The owner must discard this reader after a known history rewrite or privacy
22
+ projection change. Stat checks detect replacement, truncation and same-size
23
+ changes; no incremental reader can prove an earlier prefix was not changed
24
+ in place while also growing without rereading that prefix.
25
+ """
26
+
27
+ def __init__(self, path: Path, projector: Callable[[dict], dict | None]):
28
+ self.path = Path(path)
29
+ self.projector = projector
30
+ self._lock = threading.Lock()
31
+ self._stamp = None
32
+ self._offset = 0
33
+ self._revision = 0
34
+ self._records = 0
35
+ self._total_text = 0
36
+ self._messages: list[dict] = []
37
+ self._outputs: dict[str, list[str]] = {}
38
+ self._failed = False
39
+
40
+ def _snapshot(self) -> dict:
41
+ return {"messages": [dict(message) for message in self._messages],
42
+ "through_bytes": self._offset, "revision": str(self._revision)}
43
+
44
+ @staticmethod
45
+ def _file_stamp(value):
46
+ return (value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns)
47
+
48
+ def load(self) -> dict:
49
+ """Read only new complete records; a detected error requires a new reader."""
50
+ with self._lock:
51
+ if self._failed:
52
+ raise PublicTranscriptError("Chat history changed; reopen the conversation")
53
+ try:
54
+ return self._load()
55
+ except Exception as exc:
56
+ # The supplied projector has private state and cannot be rolled
57
+ # back after a partially consumed bad page. Never reuse it.
58
+ self._failed = True
59
+ if isinstance(exc, PublicTranscriptError):
60
+ raise
61
+ raise PublicTranscriptError("Chat history is unavailable") from exc
62
+
63
+ def _load(self) -> dict:
64
+ try:
65
+ fd = os.open(self.path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
66
+ except FileNotFoundError:
67
+ if self._stamp is None:
68
+ return self._snapshot()
69
+ raise PublicTranscriptError("Chat history changed; reopen the conversation") from None
70
+ with os.fdopen(fd, "rb") as stream:
71
+ initial = os.fstat(stream.fileno())
72
+ if not stat.S_ISREG(initial.st_mode):
73
+ raise PublicTranscriptError("Chat history is unavailable")
74
+ stamp = self._file_stamp(initial)
75
+ if initial.st_size > MAX_LOG_BYTES:
76
+ raise PublicTranscriptError("Chat exceeds the 64 MiB sharing limit")
77
+ if self._stamp is not None:
78
+ if (stamp[:2] != self._stamp[:2] or stamp[2] < self._stamp[2]
79
+ or stamp[2] == self._stamp[2] and stamp[3:] != self._stamp[3:]):
80
+ raise PublicTranscriptError("Chat history changed; reopen the conversation")
81
+ if stamp == self._stamp:
82
+ return self._snapshot()
83
+ stream.seek(self._offset)
84
+ while self._offset < initial.st_size:
85
+ line = stream.readline(min(MAX_LINE_BYTES + 1, initial.st_size - self._offset))
86
+ if len(line) > MAX_LINE_BYTES:
87
+ raise PublicTranscriptError("A chat record exceeds the sharing limit")
88
+ if not line.endswith(b"\n"):
89
+ break # Re-read this uncommitted tail only after it grows.
90
+ self._offset += len(line)
91
+ self._records += 1
92
+ if self._records > MAX_RECORDS:
93
+ raise PublicTranscriptError("Chat exceeds the sharing record limit")
94
+ try:
95
+ raw = json.loads(line)
96
+ except (ValueError, UnicodeError, RecursionError) as exc:
97
+ raise PublicTranscriptError("Chat history contains an unreadable record") from exc
98
+ if not isinstance(raw, dict) or not isinstance(raw.get("type"), str):
99
+ raise PublicTranscriptError("Chat history contains an invalid event")
100
+ self._consume(raw)
101
+ final = os.fstat(stream.fileno())
102
+ current = self.path.stat(follow_symlinks=False)
103
+ if (not stat.S_ISREG(current.st_mode)
104
+ or (current.st_dev, current.st_ino) != stamp[:2]
105
+ or final.st_size < initial.st_size
106
+ or final.st_size == initial.st_size and self._file_stamp(final)[3:] != stamp[3:]):
107
+ raise PublicTranscriptError("Chat history changed; reopen the conversation")
108
+ # Save the boundary actually read, not a later concurrent append.
109
+ self._stamp = stamp
110
+ return self._snapshot()
111
+
112
+ def _consume(self, raw: dict) -> None:
113
+ kind = raw["type"]
114
+ if kind not in {"turn_started", "assistant_text", "turn_finished", "reasoning_summary"} and not _is_goal_followup(raw):
115
+ return
116
+ if kind == "reasoning_summary" and raw.get("phase") != "commentary":
117
+ return
118
+ event = self.projector(raw)
119
+ if not event:
120
+ return
121
+ if not isinstance(event, dict):
122
+ raise PublicTranscriptError("Chat history projection is invalid")
123
+ run = str(event.get("run_id") or "")
124
+ if kind == "turn_started" or _is_goal_followup(event):
125
+ self._outputs[run] = []
126
+ role, text = "user", event.get("prompt")
127
+ else:
128
+ role = "assistant"
129
+ text = event.get("result_text") if kind == "turn_finished" else event.get("text")
130
+ if not isinstance(text, str) or not text.strip():
131
+ return
132
+ try:
133
+ size = len(text.encode("utf-8"))
134
+ except UnicodeEncodeError as exc:
135
+ raise PublicTranscriptError("Chat history contains invalid Unicode text") from exc
136
+ if size > MAX_MESSAGE_BYTES:
137
+ raise PublicTranscriptError("A message exceeds the 256 KiB sharing limit")
138
+ normalized = " ".join(text.split())
139
+ previous = self._outputs.setdefault(run, [])
140
+ if kind == "turn_finished" and (normalized in previous or normalized == " ".join(previous)):
141
+ return
142
+ if kind == "assistant_text":
143
+ previous.append(normalized)
144
+ self._total_text += size
145
+ if self._total_text > MAX_TEXT_BYTES or len(self._messages) >= MAX_MESSAGES:
146
+ raise PublicTranscriptError("Chat exceeds the 1,000-message / 2 MiB sharing limit")
147
+ message = {"role": role, "text": text}
148
+ timestamp = _public_timestamp(event.get("ts"))
149
+ if timestamp is not None:
150
+ message["timestamp"] = timestamp
151
+ self._messages.append(message)
152
+ self._revision = self._offset
@@ -0,0 +1,132 @@
1
+ """Demand-driven live projections for open shared-chat pages, never a poller."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from collections import OrderedDict
6
+ from dataclasses import dataclass, field
7
+ import secrets
8
+
9
+ from public_chat_transcript import PublicTranscriptError
10
+
11
+
12
+ @dataclass
13
+ class _OpenChat:
14
+ reader: object
15
+ generation: int = 0
16
+ identity: str = field(default_factory=lambda: secrets.token_hex(8))
17
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock)
18
+ waiters: set = field(default_factory=set)
19
+ reset_reader: bool = False
20
+
21
+ @property
22
+ def revision(self):
23
+ return f"{self.identity}:{self.generation}"
24
+
25
+
26
+ class InteractiveChatLiveState:
27
+ """One bounded incremental reader per recently viewed chat.
28
+
29
+ The existing event hub calls notify synchronously. It does only an O(1)
30
+ lookup for unrelated chats and never reads disk, sends a socket, or waits
31
+ for a guest while the owner's event delivery lock is held.
32
+ """
33
+
34
+ def __init__(self, reader_factory, session_exists, public_state=None, *, max_cached=16, native_snapshot=None):
35
+ self.reader_factory = reader_factory
36
+ self.session_exists = session_exists
37
+ self.public_state = public_state
38
+ self.native_snapshot = native_snapshot
39
+ self.max_cached = max_cached
40
+ self.entries = OrderedDict()
41
+
42
+ def notify(self, session_id, event):
43
+ entry = self.entries.get(session_id)
44
+ if entry is None:
45
+ return
46
+ kind = event.get("type", "")
47
+ if not isinstance(kind, str):
48
+ return
49
+ if self.native_snapshot is None and not (
50
+ kind in {"turn_started", "turn_finished", "turn_stopped", "turn_steered",
51
+ "assistant_text", "history_imported", "history_reconciled", "session_deleted", "turn_unqueued"}
52
+ or kind.startswith("turn_queue")
53
+ or kind == "reasoning_summary" and event.get("phase") == "commentary"
54
+ ):
55
+ return
56
+ if kind in {"history_reconciled", "history_imported"}:
57
+ # Existing bytes can acquire stricter provenance without changing
58
+ # their size. Rebuild only when a viewer next asks for the text.
59
+ entry.reset_reader = True
60
+ if self.native_snapshot is not None:
61
+ # The native renderer must replace an older history prefix,
62
+ # not merge repaired same-ID events into a stale generation.
63
+ entry.identity = secrets.token_hex(8)
64
+ entry.generation += 1
65
+ for waiter in tuple(entry.waiters):
66
+ if not waiter.done():
67
+ waiter.set_result(True)
68
+
69
+ async def load(self, session_id):
70
+ if not self.session_exists(session_id):
71
+ raise PublicTranscriptError("Chat is unavailable")
72
+ entry = self.entries.get(session_id)
73
+ if entry is None:
74
+ if len(self.entries) >= self.max_cached:
75
+ candidate = next((key for key, item in self.entries.items()
76
+ if not item.waiters and not item.lock.locked()), None)
77
+ if candidate is None:
78
+ raise PublicTranscriptError("Shared chat viewers are busy; retry shortly")
79
+ self.entries.pop(candidate)
80
+ entry = _OpenChat(None if self.native_snapshot is not None else self.reader_factory(session_id))
81
+ self.entries[session_id] = entry
82
+ self.entries.move_to_end(session_id)
83
+ async with entry.lock:
84
+ if entry.reset_reader:
85
+ if self.native_snapshot is None:
86
+ entry.reader = self.reader_factory(session_id)
87
+ entry.reset_reader = False
88
+ # Capture before I/O: a concurrent append then makes wait() return
89
+ # immediately, closing the initial snapshot-to-subscription gap.
90
+ revision = entry.revision
91
+ # Native views need tool/progress/settings/approval updates too;
92
+ # notify still performs no I/O and unopened chats have no entry.
93
+ task = asyncio.create_task(self.native_snapshot(session_id) if self.native_snapshot is not None
94
+ else asyncio.to_thread(entry.reader.load))
95
+ try:
96
+ snapshot = await asyncio.shield(task)
97
+ except asyncio.CancelledError:
98
+ # Do not release the reader's lock while its thread is alive.
99
+ while not task.done():
100
+ try:
101
+ await asyncio.shield(task)
102
+ except asyncio.CancelledError:
103
+ continue
104
+ except Exception:
105
+ break
106
+ if not task.cancelled():
107
+ task.exception()
108
+ raise
109
+ except Exception:
110
+ self.entries.pop(session_id, None)
111
+ raise
112
+ if not self.session_exists(session_id):
113
+ self.entries.pop(session_id, None)
114
+ raise PublicTranscriptError("Chat is unavailable")
115
+ if self.native_snapshot is not None:
116
+ return {**snapshot, "revision": revision}
117
+ state = self.public_state(session_id)
118
+ return {"messages": [*snapshot["messages"], *state.get("queued", [])],
119
+ "revision": revision, "busy": state.get("busy") is True}
120
+
121
+ async def wait(self, session_id, revision, timeout):
122
+ entry = self.entries.get(session_id)
123
+ if entry is None or entry.revision != revision:
124
+ return True
125
+ waiter = asyncio.get_running_loop().create_future()
126
+ entry.waiters.add(waiter)
127
+ try:
128
+ return await asyncio.wait_for(waiter, timeout=timeout)
129
+ except asyncio.TimeoutError:
130
+ return False
131
+ finally:
132
+ entry.waiters.discard(waiter)