@agentsdock/server 1.0.11 → 1.0.12-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.
@@ -2369,17 +2369,20 @@ class CodexAppServerClient:
2369
2369
  validated["turn"] = _protocol_turn(method, result.get("turn"))
2370
2370
  return validated
2371
2371
 
2372
- async def run_thread_shell_command(self, thread_id: str, command: str) -> None:
2372
+ async def run_thread_shell_command(
2373
+ self, thread_id: str, command: str, *,
2374
+ before_send: Callable[[], bool] | None = None,
2375
+ ) -> None:
2373
2376
  thread_id = _require_nonempty_string(thread_id, "thread_id")
2374
2377
  command = _require_nonempty_string(command, "command")
2375
2378
  method = "thread/shellCommand"
2376
- _protocol_empty_object(
2377
- method,
2378
- await self.request(
2379
- method,
2380
- {"threadId": thread_id, "command": command},
2381
- ),
2382
- )
2379
+ params = {"threadId": thread_id, "command": command}
2380
+ if before_send is None:
2381
+ result = await self.request(method, params)
2382
+ else:
2383
+ await self.start()
2384
+ result = await self._request_connected(method, params, before_send=before_send)
2385
+ _protocol_empty_object(method, result)
2383
2386
 
2384
2387
  async def list_background_terminals(
2385
2388
  self,
@@ -3011,8 +3014,14 @@ class CodexAppServerManager:
3011
3014
  delivery=delivery,
3012
3015
  )
3013
3016
 
3014
- async def run_thread_shell_command(self, thread_id: str, command: str) -> None:
3015
- await self.client.run_thread_shell_command(thread_id, command)
3017
+ async def run_thread_shell_command(
3018
+ self, thread_id: str, command: str, *,
3019
+ before_send: Callable[[], bool] | None = None,
3020
+ ) -> None:
3021
+ if before_send is None:
3022
+ await self.client.run_thread_shell_command(thread_id, command)
3023
+ else:
3024
+ await self.client.run_thread_shell_command(thread_id, command, before_send=before_send)
3016
3025
 
3017
3026
  async def list_background_terminals(
3018
3027
  self,
@@ -23,7 +23,7 @@ SIDE_INSTRUCTIONS = """Answer questions and explore in this separate side chat w
23
23
 
24
24
  Treat inherited messages, tasks, plans, tool calls and approvals as reference material, not current instructions or permission. Follow only requests made in this side chat; do not resume unfinished parent work.
25
25
 
26
- Use the thread's existing permissions and available tools, including external tools, to read or search files and run checks that leave repo-tracked files unchanged. Do not create, contact or control subagents.
26
+ Use the thread's existing permissions and available tools, including external tools and subagents. Delegate side-chat work only to subagents created in this side chat; inherited parent agents belong to the main conversation.
27
27
 
28
28
  Change files, git state, configuration, permissions or other workspace state only when explicitly requested here. Request escalation only when such an explicit mutation requires it. Keep authorized changes limited to the request and preserve ongoing parent work."""
29
29
 
@@ -68,8 +68,6 @@ def side_chat_config(parent_config: dict | None = None) -> dict:
68
68
  if isinstance(servers, dict):
69
69
  servers.pop(name, None)
70
70
  config.update({
71
- "features.multi_agent": False, "features.multi_agent_v2": False,
72
- "agents.enabled": False,
73
71
  f"{prefix}.enabled": False, f"{prefix}.required": False,
74
72
  # Codex validates transport shape even for a disabled server. Never
75
73
  # copy the parent's authenticated helper URL/headers into this child.
@@ -182,6 +180,9 @@ class NativeCodexSideChat:
182
180
  self._progress = None
183
181
  self._lock = asyncio.Lock()
184
182
  self._closed = False
183
+ self._thread_parents: dict[str, str] = {}
184
+ self._owned_threads: set[str] = set()
185
+ self._work_revision = 0
185
186
 
186
187
  @property
187
188
  def closed(self) -> bool:
@@ -193,12 +194,88 @@ class NativeCodexSideChat:
193
194
  return None
194
195
  return deepcopy(list(self._progress.items.values()))
195
196
 
197
+ def owns_thread(self, thread_id: str) -> bool:
198
+ """Only the side fork and provider-verified descendants own approvals."""
199
+ return bool(not self._closed and self.thread_id and thread_id != self.parent_thread_id
200
+ and (thread_id == self.thread_id or thread_id in self._owned_threads))
201
+
202
+ def _remember_thread(self, thread):
203
+ if not isinstance(thread, dict):
204
+ return
205
+ thread_id = thread.get("id")
206
+ source = thread.get("source")
207
+ subagent = source.get("subAgent") if isinstance(source, dict) else None
208
+ spawn = subagent.get("thread_spawn") if isinstance(subagent, dict) else None
209
+ parent = spawn.get("parent_thread_id") if isinstance(spawn, dict) else None
210
+ if isinstance(thread_id, str) and isinstance(parent, str) and parent:
211
+ self._thread_parents[thread_id] = parent
212
+
213
+ def _remember_notification(self, notification):
214
+ if notification.get("method") in {"thread/started", "thread/closed", "thread/status/changed", "turn/started", "turn/completed"}:
215
+ self._work_revision += 1
216
+ if notification.get("method") == "thread/started":
217
+ self._remember_thread((notification.get("params") or {}).get("thread"))
218
+
219
+ def background_work_scope(self):
220
+ client = self._client
221
+ if self._closed or client is None or not client.ready or not self.thread_id:
222
+ return None
223
+ return (self, client, client.generation, self.thread_id, self._work_revision)
224
+
225
+ async def has_background_work(self):
226
+ scope = self.background_work_scope()
227
+ if scope is None:
228
+ return False
229
+ _owner, client, _generation, thread_id, _revision = scope
230
+ descendants = await client.list_descendant_threads(thread_id)
231
+ if self.background_work_scope() != scope:
232
+ raise SideQuestionError(409, "Side chat changed during background inspection")
233
+ # This native relationship-filtered API sees descendants even when
234
+ # app-server omitted their thread/started notification.
235
+ for child in descendants:
236
+ child_id = child.get("id")
237
+ status = child.get("status")
238
+ kind = status.get("type") if isinstance(status, dict) else None
239
+ if child_id in {thread_id, self.parent_thread_id} or not isinstance(child_id, str):
240
+ raise SideQuestionError(502, "Codex returned an invalid side descendant")
241
+ if kind == "active":
242
+ return True
243
+ if kind not in {"idle", "notLoaded"}:
244
+ raise SideQuestionError(502, "Codex side descendant activity is unknown")
245
+ return False
246
+
247
+ async def _verify_owned_thread(self, thread_id: str) -> bool:
248
+ if self.owns_thread(thread_id):
249
+ return True
250
+ if not self.thread_id or self._client is None or self._closed:
251
+ return False
252
+ path: set[str] = set()
253
+ current = thread_id
254
+ while current and current not in path and current != self.parent_thread_id:
255
+ if self.owns_thread(current):
256
+ self._owned_threads.update(path)
257
+ return True
258
+ path.add(current)
259
+ if current not in self._thread_parents:
260
+ try:
261
+ thread = await self._client.read_thread(current, include_turns=False)
262
+ except CodexAppServerError:
263
+ return False
264
+ if self._closed or not isinstance(thread, dict) or thread.get("id") != current:
265
+ return False
266
+ self._remember_thread(thread)
267
+ current = self._thread_parents.get(current, "")
268
+ return False
269
+
196
270
  async def _handle_server_request(self, request_id, method, params):
197
- # The callback can present approvals through the existing UI, but only
198
- # for this exact child. Never route a parent's inherited tool request.
271
+ # Provider lineage permits side-chat descendants without granting a
272
+ # parent's inherited tool request or unrelated thread any authority.
199
273
  if (self.server_request_handler is not None and not self._closed
200
- and self._active is not None and self.thread_id is not None
201
- and params.get("threadId") == self.thread_id):
274
+ and self.thread_id is not None
275
+ and isinstance(params.get("threadId"), str)
276
+ and await self._verify_owned_thread(params["threadId"])
277
+ and not self._closed
278
+ and (self._active is not None or params["threadId"] != self.thread_id)):
202
279
  return await self.server_request_handler(request_id, method, params)
203
280
  return await decline_server_request(request_id, method, params)
204
281
 
@@ -241,6 +318,7 @@ class NativeCodexSideChat:
241
318
  on_process_started=lambda _pid, _group: self._process_started.set(),
242
319
  **client_options,
243
320
  )
321
+ self._client.add_notification_handler(self._remember_notification)
244
322
  await self._client.start()
245
323
  if self._closed:
246
324
  raise SideQuestionError(409, "Side chat was closed; open a new side chat")
@@ -18,6 +18,7 @@ draining; this module deliberately contains no process lifecycle state.
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
+ import hashlib
21
22
  import json
22
23
  import re
23
24
  from typing import Any, Iterator
@@ -31,6 +32,13 @@ CURSOR_SESSION_IDENTIFIER_RE = re.compile(
31
32
  r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$"
32
33
  )
33
34
  CURSOR_CALL_ID_MAX_CHARS = 240
35
+ # Cursor 2026.10.22 emits this compound correlation key even for a normal
36
+ # file-read tool. LF is part of the opaque ID, not a second stream event.
37
+ _CURSOR_UUID_PATTERN = r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}"
38
+ _CURSOR_COMPOSITE_CALL_ID_RE = re.compile(
39
+ rf"call-{_CURSOR_UUID_PATTERN}-[0-9]+\nfc_{_CURSOR_UUID_PATTERN}_[0-9]+"
40
+ )
41
+ _CURSOR_ENCODED_CALL_ID_PREFIX = "cursor-call-sha256:"
34
42
  CURSOR_LIFECYCLE_VALUE_MAX = 2_147_483_647
35
43
  CURSOR_ANSI_ESCAPE_RE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))")
36
44
  CURSOR_TOOL_SUCCESS_OUTCOMES = (
@@ -90,7 +98,13 @@ def canonical_cursor_session_id(value: Any) -> str | None:
90
98
 
91
99
 
92
100
  def canonical_cursor_call_id(value: Any) -> str:
93
- """Validate the opaque correlation key used by Cursor tool events."""
101
+ """Bound an opaque tool key and encode the observed compound CLI format.
102
+
103
+ Keep the complete key in the digest: stripping LF, keeping only one half,
104
+ or assigning a fresh ID per event could merge distinct tools or orphan a
105
+ completion. The encoded namespace is reserved so literal provider IDs
106
+ cannot collide with it. Arbitrary control characters remain invalid.
107
+ """
94
108
 
95
109
  if not isinstance(value, str):
96
110
  raise CursorEventParseError("tool_call call_id must be a string")
@@ -98,11 +112,17 @@ def canonical_cursor_call_id(value: Any) -> str:
98
112
  not value
99
113
  or value != value.strip()
100
114
  or len(value) > CURSOR_CALL_ID_MAX_CHARS
101
- or any(ord(character) < 32 or ord(character) == 127 for character in value)
102
115
  ):
103
116
  raise CursorEventParseError(
104
117
  "tool_call call_id must be a bounded nonempty identifier"
105
118
  )
119
+ composite = _CURSOR_COMPOSITE_CALL_ID_RE.fullmatch(value) is not None
120
+ if not composite and any(ord(character) < 32 or ord(character) == 127 for character in value):
121
+ raise CursorEventParseError(
122
+ "tool_call call_id must be a bounded nonempty identifier"
123
+ )
124
+ if composite or value.startswith(_CURSOR_ENCODED_CALL_ID_PREFIX):
125
+ return _CURSOR_ENCODED_CALL_ID_PREFIX + hashlib.sha256(value.encode("utf-8", "surrogatepass")).hexdigest()
106
126
  return value
107
127
 
108
128
 
package/server/install.sh CHANGED
@@ -1846,7 +1846,7 @@ if [[ "$TEAM_HUB_MODE" == "host" && "$TEAM_HUB_OPERATION_PENDING" != "true" \
1846
1846
  fi
1847
1847
  fi
1848
1848
 
1849
- RELEASE_FILES=(activation_transaction.py execution_activation.py execution_legacy_runner.py execution_preparation.py update_preparation.py update_handoff.py update_recovery.py execution_update_status.py execution_uninstall.py execution_recovery.py execution_recovery_status.py execution_http.py execution_durability.py execution_control.py execution_install.py execution_maintenance.py execution_manage.py execution_ownership.py execution_service.py execution_transport.py agent_server.py local_session_ownership.py cursor_history.py workspace_git.py team_hub_host.py secure_peer_runtime.py team_mail_runtime.py team_mail_websocket.py team_mail_grants.py secure_peer_delivery.py agentsdock_jobs.py agentsdock_chats.py chat_mailbox.py agentsdock_emergency.py agentsdock_publish.py agentsdock_mail.py agentsdock_team.py provider_commands.py provider_usage.py claude_sdk_client.py claude_model_catalog.py claude_goals.py claude_background_reconciliation.py codex_app_server.py codex_auth.py codex_provider.py codex_response_stream.py provider_connections.py cursor_api_key.py side_questions.py title_generation.py codex_side_question.py codex_side_chat_progress.py claude_side_question.py cursor_agent_client.py cursor_provider_mcp.py opencode_agent_client.py gemini_acp_client.py gemini_history.py gemini_content.py gemini_import.py gemini_runtime.py gemini_runner.py gemini_permissions.py gemini_api.py cursor_process_guard.py transcript_reader.py claude_history_repair.py claude_history_provenance.py codex_history_repair.py public_chat_shares.py public_chat_transcript.py public_chat_share_routes.py interactive_chat_shares.py interactive_chat_share_routes.py interactive_chat_share_web.py interactive_chat_projection.py interactive_chat_runtime.py interactive_chat_native.py shared_chat_videos.py shared_chat_analytics_web.py shared_chat_video_stream.py interactive_chat_controls.py install.sh uninstall.sh instances.sh server_instances.py npm/agentsdock/pair.cjs npm/agentsdock/qr-renderer.cjs npm/agentsdock/QR-NOTICES.txt update_runner.py pyproject.toml uv.lock VERSION release-public-key.pem LICENSE NOTICE)
1849
+ RELEASE_FILES=(activation_transaction.py execution_activation.py execution_legacy_runner.py execution_preparation.py update_preparation.py update_handoff.py update_recovery.py execution_update_status.py execution_uninstall.py execution_recovery.py execution_recovery_status.py execution_http.py execution_durability.py execution_control.py execution_install.py execution_maintenance.py execution_manage.py execution_ownership.py execution_service.py execution_transport.py agent_server.py local_session_ownership.py cursor_history.py workspace_git.py team_hub_host.py secure_peer_runtime.py team_mail_runtime.py team_mail_websocket.py team_mail_grants.py secure_peer_delivery.py agentsdock_jobs.py agentsdock_chats.py chat_mailbox.py agentsdock_emergency.py agentsdock_notify.py agentsdock_publish.py agentsdock_mail.py agentsdock_team.py provider_commands.py provider_usage.py session_unread.py claude_sdk_client.py claude_model_catalog.py claude_goals.py claude_background_reconciliation.py codex_app_server.py codex_auth.py codex_provider.py codex_response_stream.py provider_connections.py cursor_api_key.py side_questions.py title_generation.py codex_side_question.py codex_side_chat_progress.py claude_side_question.py cursor_agent_client.py cursor_provider_mcp.py opencode_agent_client.py gemini_acp_client.py gemini_history.py gemini_content.py gemini_import.py gemini_runtime.py gemini_runner.py gemini_permissions.py gemini_api.py cursor_process_guard.py transcript_reader.py claude_history_repair.py claude_history_provenance.py codex_history_repair.py public_chat_shares.py public_chat_transcript.py public_chat_share_routes.py interactive_chat_shares.py interactive_chat_share_routes.py interactive_chat_share_web.py interactive_chat_projection.py interactive_chat_runtime.py interactive_chat_native.py shared_chat_videos.py shared_chat_analytics_web.py shared_chat_video_stream.py interactive_chat_controls.py install.sh uninstall.sh instances.sh server_instances.py npm/agentsdock/pair.cjs npm/agentsdock/qr-renderer.cjs npm/agentsdock/QR-NOTICES.txt update_runner.py pyproject.toml uv.lock VERSION release-public-key.pem LICENSE NOTICE)
1850
1850
  RELEASE_DIRECTORIES=(agentsdock_team_hub)
1851
1851
  TEAM_HUB_RELEASE_FILES=(
1852
1852
  __init__.py
@@ -3753,11 +3753,13 @@ validate_staged_release_runtime() (
3753
3753
  "$STAGE_DIR/agentsdock_chats.py" \
3754
3754
  "$STAGE_DIR/chat_mailbox.py" \
3755
3755
  "$STAGE_DIR/agentsdock_emergency.py" \
3756
+ "$STAGE_DIR/agentsdock_notify.py" \
3756
3757
  "$STAGE_DIR/agentsdock_publish.py" \
3757
3758
  "$STAGE_DIR/agentsdock_mail.py" \
3758
3759
  "$STAGE_DIR/agentsdock_team.py" \
3759
3760
  "$STAGE_DIR/provider_commands.py" \
3760
3761
  "$STAGE_DIR/provider_usage.py" \
3762
+ "$STAGE_DIR/session_unread.py" \
3761
3763
  "$STAGE_DIR/claude_sdk_client.py" \
3762
3764
  "$STAGE_DIR/claude_model_catalog.py" \
3763
3765
  "$STAGE_DIR/claude_goals.py" \
@@ -4132,7 +4134,7 @@ for name in "${TEAM_HUB_RELEASE_FILES[@]}"; do
4132
4134
  "$SOURCE_DIR/agentsdock_team_hub/$name" \
4133
4135
  "$STAGE_DIR/agentsdock_team_hub/$name"
4134
4136
  done
4135
- chmod 755 "$STAGE_DIR/agent_server.py" "$STAGE_DIR/agentsdock_jobs.py" "$STAGE_DIR/agentsdock_chats.py" "$STAGE_DIR/agentsdock_emergency.py" "$STAGE_DIR/agentsdock_publish.py" "$STAGE_DIR/agentsdock_mail.py" "$STAGE_DIR/agentsdock_team.py" "$STAGE_DIR/install.sh" "$STAGE_DIR/uninstall.sh" "$STAGE_DIR/update_runner.py"
4137
+ chmod 755 "$STAGE_DIR/agent_server.py" "$STAGE_DIR/agentsdock_jobs.py" "$STAGE_DIR/agentsdock_chats.py" "$STAGE_DIR/agentsdock_emergency.py" "$STAGE_DIR/agentsdock_notify.py" "$STAGE_DIR/agentsdock_publish.py" "$STAGE_DIR/agentsdock_mail.py" "$STAGE_DIR/agentsdock_team.py" "$STAGE_DIR/install.sh" "$STAGE_DIR/uninstall.sh" "$STAGE_DIR/update_runner.py"
4136
4138
  chmod 755 "$STAGE_DIR/instances.sh"
4137
4139
 
4138
4140
  if [[ "$PREPARE_ONLY" == "true" ]]; then
@@ -28,11 +28,19 @@ class PublicTranscriptError(ValueError):
28
28
  def _is_goal_followup(event: dict) -> bool:
29
29
  return bool(
30
30
  event.get("type") == "turn_steered"
31
- and event.get("native_goal_steer") is True
32
31
  and event.get("native_steer") is True
33
32
  and event.get("provider_user_authored") is True
34
- and event.get("backend") == "codex"
35
- and event.get("purpose") == "codex_goal_resume"
33
+ and (
34
+ (event.get("native_goal_steer") is True
35
+ and event.get("backend") == "codex"
36
+ and event.get("purpose") == "codex_goal_resume")
37
+ or (event.get("native_followup") is True
38
+ and event.get("backend") == "claude"
39
+ and event.get("purpose") is None
40
+ and event.get("provider_generated") is not True
41
+ and isinstance(event.get("provider_message_id"), str)
42
+ and event["provider_message_id"].strip())
43
+ )
36
44
  and isinstance(event.get("run_id"), str)
37
45
  and event["run_id"].strip()
38
46
  )
@@ -0,0 +1,184 @@
1
+ """Incremental unread provenance; journal recovery runs off the event loop."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Any, Callable, Iterable
6
+ from transcript_reader import jsonl_records
7
+
8
+
9
+ COUNTER = "latest_non_job_agent_event_seq"
10
+ CHECKPOINT = "_non_job_agent_event_checkpoint_seq"
11
+ PENDING_COUNTER = "_non_job_agent_event_pending_seq"
12
+ LAST_RUN = "_non_job_agent_last_run"
13
+ JOURNAL = "_non_job_agent_event_journal"
14
+
15
+
16
+ def imported_event(event: dict[str, Any]) -> bool:
17
+ return event.get("imported") is True or str(event.get("run_id") or "").startswith("import_")
18
+
19
+
20
+ def scheduled_event(event: dict[str, Any]) -> bool:
21
+ job = event.get("job")
22
+ return bool(
23
+ event.get("purpose") == "scheduled_job"
24
+ or str(event.get("job_id") or "").strip()
25
+ or isinstance(job, dict) and str(job.get("id") or "").strip()
26
+ or str(event.get("type") or "").startswith("job_")
27
+ )
28
+
29
+
30
+ def project_event(
31
+ session: dict[str, Any], event: dict[str, Any], *,
32
+ visible: bool, run_metadata: dict[str, Any],
33
+ ) -> None:
34
+ """Apply an appended event without I/O or changing the ordinary read cursor."""
35
+ seq = int(event.get("seq") or 0)
36
+ if seq <= 0:
37
+ return
38
+ # Only a new journal can establish an empty baseline without recovery.
39
+ if seq == 1 and not int(session.get("latest_event_seq") or 0):
40
+ session.setdefault(COUNTER, 0)
41
+ session.setdefault(CHECKPOINT, 0)
42
+ known = isinstance(session.get(COUNTER), int) and CHECKPOINT in session
43
+ run_id = str(event.get("run_id") or "")
44
+ contexts: list[dict[str, Any]] = []
45
+ if event.get("type") == "turn_started" and run_id:
46
+ context = {"run_id": run_id, "scheduled_job": scheduled_event(event) or scheduled_event(run_metadata)}
47
+ session[LAST_RUN] = context
48
+ contexts.append(context)
49
+ else:
50
+ for candidate in (session.get("active_run"), session.get(LAST_RUN)):
51
+ if isinstance(candidate, dict) and run_id and candidate.get("run_id") == run_id:
52
+ contexts.append(candidate)
53
+ scheduled = scheduled_event(event) or scheduled_event(run_metadata) or any(
54
+ scheduled_event(context) or context.get("scheduled_job") is True for context in contexts
55
+ )
56
+ if scheduled and run_id and (session.get(LAST_RUN) or {}).get("run_id") == run_id:
57
+ session[LAST_RUN] = {"run_id": run_id, "scheduled_job": True}
58
+ if visible and not scheduled and not imported_event(event):
59
+ field = COUNTER if known else PENDING_COUNTER
60
+ session[field] = max(int(session.get(field) or 0), seq)
61
+ if known:
62
+ session[CHECKPOINT] = max(int(session.get(CHECKPOINT) or 0), seq)
63
+
64
+
65
+ def recover_projection(
66
+ path: Path, snapshot: dict[str, Any], *,
67
+ visible: Callable[[str, dict[str, Any]], bool],
68
+ resume_offset: Callable[[Path, int], int],
69
+ prior_events: Callable[[], Iterable[dict[str, Any]]],
70
+ cancelled: Callable[[], bool],
71
+ ) -> dict[str, Any]:
72
+ """Project one fixed journal extent, then resolve missing run provenance once.
73
+
74
+ Legacy chats need one forward pass. A durable checkpoint resumes through the
75
+ existing sparse index; only an untagged tail run whose start precedes that
76
+ checkpoint requires a backwards provenance pass. No history is read by the
77
+ session-list endpoint or the live event projection.
78
+ """
79
+ known = isinstance(snapshot.get(COUNTER), int) and CHECKPOINT in snapshot
80
+ after = int(snapshot.get(CHECKPOINT) or 0) if known else 0
81
+ latest = int(snapshot.get(COUNTER) or 0) if known else 0
82
+ through = after
83
+ last_run = dict(snapshot.get(LAST_RUN) or {})
84
+ runs: dict[str, dict[str, Any]] = {}
85
+ unresolved: dict[str, list[dict[str, Any]]] = {}
86
+
87
+ def finish(run_id: str, run: dict[str, Any]) -> None:
88
+ nonlocal latest
89
+ if run["scheduled"] or not run["visible_seq"]:
90
+ return
91
+ if run["origin"]:
92
+ latest = max(latest, run["visible_seq"])
93
+ else:
94
+ unresolved.setdefault(run_id, []).append(run)
95
+
96
+ try:
97
+ source = path.open("rb")
98
+ except FileNotFoundError:
99
+ if after or int(snapshot.get("latest_event_seq") or 0):
100
+ raise
101
+ return {COUNTER: 0, CHECKPOINT: 0, LAST_RUN: {}}
102
+ with source:
103
+ extent = source.seek(0, 2)
104
+ inode = path.stat().st_ino
105
+ journal = snapshot.get(JOURNAL) or {}
106
+ if known and journal and (journal.get("inode") != inode or int(journal.get("offset") or 0) > extent):
107
+ after = latest = through = 0
108
+ last_run = {}
109
+ offset = int(journal.get("offset") or 0) if after and journal.get("inode") == inode else 0
110
+ source.seek(offset or (resume_offset(path, after) if after else 0))
111
+ complete_offset = source.tell()
112
+ for event, ending, raw in jsonl_records(
113
+ source, end=extent, project=False, require_newline=False, deadline=float("inf"), cancelled=cancelled,
114
+ max_records=float("inf"), max_nodes=None, max_depth=512,
115
+ retained_fields=frozenset({"seq", "type", "run_id", "purpose", "job_id", "job", "text", "result_text", "goal", "imported"}),
116
+ ):
117
+ if not raw.complete:
118
+ break # Leave a concurrently written/crash fragment unclaimed.
119
+ complete_offset = ending
120
+ if not isinstance(event, dict):
121
+ continue
122
+ seq = int(event.get("seq") or 0)
123
+ if seq <= after:
124
+ continue
125
+ through = max(through, seq)
126
+ event_type = str(event.get("type") or "")
127
+ is_visible = visible(event_type, event) and not imported_event(event)
128
+ run_id = str(event.get("run_id") or "")
129
+ scheduled = scheduled_event(event)
130
+ if not run_id:
131
+ if is_visible and not scheduled:
132
+ latest = max(latest, seq)
133
+ continue
134
+ if event_type == "turn_started" and run_id in runs:
135
+ finish(run_id, runs.pop(run_id))
136
+ run = runs.setdefault(run_id, {"first_seq": seq, "visible_seq": 0, "scheduled": False, "origin": False})
137
+ run["scheduled"] = run["scheduled"] or scheduled
138
+ if event_type == "turn_started":
139
+ run["origin"] = True
140
+ last_run = {"run_id": run_id, "scheduled_job": scheduled}
141
+ elif scheduled and last_run.get("run_id") == run_id:
142
+ last_run["scheduled_job"] = True
143
+ if is_visible:
144
+ run["visible_seq"] = max(run["visible_seq"], seq)
145
+ if event_type in {"turn_finished", "turn_stopped"} and (run["scheduled"] or not run["visible_seq"]):
146
+ # Completed cron runs do not accumulate with the schedule's
147
+ # history. A later untagged event is resolved by the single
148
+ # backwards provenance pass, just like a restart tail.
149
+ runs.pop(run_id, None)
150
+ for run_id, run in runs.items():
151
+ finish(run_id, run)
152
+ # One reverse pass resolves all tail runs together, including stripped raw
153
+ # provider events and a job_ran marker emitted after the provider's start.
154
+ if unresolved:
155
+ for event in prior_events():
156
+ if cancelled():
157
+ raise InterruptedError("unread projection cancelled")
158
+ run_id = str(event.get("run_id") or "")
159
+ candidates = unresolved.get(run_id)
160
+ if not candidates:
161
+ continue
162
+ seq = int(event.get("seq") or 0)
163
+ scheduled = scheduled_event(event)
164
+ if not scheduled and event.get("type") != "turn_started":
165
+ continue
166
+ remaining = []
167
+ for run in candidates:
168
+ if seq >= run["first_seq"]:
169
+ remaining.append(run)
170
+ elif not scheduled:
171
+ latest = max(latest, run["visible_seq"])
172
+ if remaining:
173
+ unresolved[run_id] = remaining
174
+ else:
175
+ unresolved.pop(run_id)
176
+ if not unresolved:
177
+ break
178
+ # Legacy output with no recorded job provenance remains ordinary;
179
+ # merely owning a schedule never suppresses a human conversation.
180
+ for candidates in unresolved.values():
181
+ for run in candidates:
182
+ latest = max(latest, run["visible_seq"])
183
+ return {COUNTER: latest, CHECKPOINT: through, LAST_RUN: last_run,
184
+ JOURNAL: {"inode": inode, "offset": complete_offset}}
@@ -27,10 +27,11 @@ from fastapi import APIRouter, HTTPException, Request
27
27
  from fastapi.responses import JSONResponse
28
28
 
29
29
 
30
- MAX_QUESTION_CHARS = 8000
31
30
  MAX_HISTORY_ITEMS = 32
32
31
  MAX_HISTORY_CHARS = 60000
33
32
  MAX_REQUEST_BYTES = 512 * 1024
33
+ # The streamed body limit bounds input; avoid a second, smaller model-text cap.
34
+ MAX_QUESTION_CHARS = MAX_REQUEST_BYTES
34
35
  MAX_CONTEXT_CHARS = 60000
35
36
  MAX_LOG_BYTES = 4 * 1024 * 1024
36
37
  MAX_OUTPUT_BYTES = 1024 * 1024
@@ -369,11 +370,6 @@ class _NativeConversation:
369
370
  closed: bool = False
370
371
  timer: object = None
371
372
  close_task: asyncio.Task | None = None
372
-
373
-
374
- NATIVE_IDLE_SECONDS = 30 * 60
375
-
376
-
377
373
  class SideQuestions:
378
374
  def __init__(self, answer=None, *, native_factory=None, storage_path=None, notify=None, admission_check=None, runtime_settings_for_session=None):
379
375
  self.answer = answer
@@ -396,6 +392,43 @@ class SideQuestions:
396
392
  def active_work_labels(self):
397
393
  return [f"Side chat in {session_id}" for session_id in sorted(self.active_session_ids())]
398
394
 
395
+ def background_work_scopes(self):
396
+ handles = [(key[1], conversation.handle) for key, conversation in self.conversations.items()
397
+ if not conversation.closed and conversation.handle is not None]
398
+ if self.synced is not None:
399
+ handles.extend((key[1], handle) for key, handle in self.synced.handles.items())
400
+ scopes = []
401
+ for session_id, handle in handles:
402
+ inspect = getattr(handle, "background_work_scope", None)
403
+ scope = inspect() if callable(inspect) else None
404
+ if scope is not None:
405
+ scopes.append((session_id, handle, scope))
406
+ return tuple(scopes)
407
+
408
+ async def prepare_background_work_snapshot(self, *, timeout_seconds=5.0):
409
+ scopes = self.background_work_scopes()
410
+
411
+ async def inspect(session_id, handle, _scope):
412
+ try:
413
+ active = await handle.has_background_work()
414
+ except Exception:
415
+ return f"Side chat provider state unknown in {session_id}"
416
+ return f"Side chat background work in {session_id}" if active else None
417
+
418
+ try:
419
+ values = await asyncio.wait_for(asyncio.gather(*(inspect(*scope) for scope in scopes)),
420
+ timeout=timeout_seconds)
421
+ labels = tuple(sorted(set(value for value in values if value)))
422
+ except asyncio.TimeoutError:
423
+ labels = tuple(f"Side chat provider state unknown in {session_id}" for session_id, _, _ in scopes)
424
+ return {"scopes": scopes, "labels": labels}
425
+
426
+ def background_work_labels_from_snapshot(self, snapshot):
427
+ scopes = self.background_work_scopes()
428
+ if snapshot.get("scopes", ()) != scopes:
429
+ return ["Side chat provider state changed during inspection"]
430
+ return list(snapshot.get("labels", ()))
431
+
399
432
  async def _close_conversation(self, conversation):
400
433
  conversation.closed = True
401
434
  if conversation.timer is not None:
@@ -415,7 +448,7 @@ class SideQuestions:
415
448
  await asyncio.shield(conversation.close_task)
416
449
 
417
450
  def _expire_conversation(self, key, conversation):
418
- if self.conversations.get(key) is not conversation or conversation.busy:
451
+ if self.conversations.get(key) is not conversation or conversation.busy or not conversation.closed:
419
452
  return
420
453
  self.conversations.pop(key, None)
421
454
  task = asyncio.create_task(self._close_conversation(conversation), name="side-chat-expiry")
@@ -461,8 +494,9 @@ class SideQuestions:
461
494
  raise
462
495
  finally:
463
496
  conversation.busy = False
464
- conversation.timer = asyncio.get_running_loop().call_later(
465
- NATIVE_IDLE_SECONDS, self._expire_conversation, key, conversation)
497
+ if conversation.closed:
498
+ conversation.timer = asyncio.get_running_loop().call_later(
499
+ RECEIPT_TTL_SECONDS, self._expire_conversation, key, conversation)
466
500
 
467
501
  async def close_conversation(self, owner, session_id, side_chat_id):
468
502
  key = (owner, session_id, side_chat_id)
@@ -473,7 +507,7 @@ class SideQuestions:
473
507
  await self.cancel(*receipt_key)
474
508
  await self._close_conversation(conversation)
475
509
  conversation.timer = asyncio.get_running_loop().call_later(
476
- NATIVE_IDLE_SECONDS, self._expire_conversation, key, conversation)
510
+ RECEIPT_TTL_SECONDS, self._expire_conversation, key, conversation)
477
511
 
478
512
  def _prune(self):
479
513
  now = time.monotonic()
@@ -874,8 +908,6 @@ class SyncedSideChats:
874
908
  self.cleanup_tasks.discard(cleanup)
875
909
  if self.tasks.get(key) is asyncio.current_task():
876
910
  self.tasks.pop(key, None)
877
- if not self.stopping:
878
- self.timers[key] = asyncio.get_running_loop().call_later(NATIVE_IDLE_SECONDS, self._expire, key)
879
911
 
880
912
  async def _finish(self, key, side_chat_id, request_id, status, **result):
881
913
  async with self._lock(key):
@@ -1053,7 +1085,7 @@ def create_side_question_router(*, authorize, session_exists, runtime: SideQuest
1053
1085
  if not isinstance(request_id, str) or not IDENTIFIER.fullmatch(request_id):
1054
1086
  raise HTTPException(400, "Invalid side question request ID")
1055
1087
  if not isinstance(question, str) or not question.strip() or len(question) > MAX_QUESTION_CHARS:
1056
- raise HTTPException(400, "Question must contain 1 to 8000 characters")
1088
+ raise HTTPException(400, f"Question must contain 1 to {MAX_QUESTION_CHARS} characters")
1057
1089
  try:
1058
1090
  question.encode("utf-8")
1059
1091
  except UnicodeEncodeError:
@@ -1152,7 +1184,7 @@ def create_side_question_router(*, authorize, session_exists, runtime: SideQuest
1152
1184
  raise HTTPException(400, "Invalid side conversation ID")
1153
1185
  question = value["question"]
1154
1186
  if not isinstance(question, str) or not question.strip() or len(question) > MAX_QUESTION_CHARS:
1155
- raise HTTPException(400, "Question must contain 1 to 8000 characters")
1187
+ raise HTTPException(400, f"Question must contain 1 to {MAX_QUESTION_CHARS} characters")
1156
1188
  try:
1157
1189
  question.encode("utf-8")
1158
1190
  except UnicodeEncodeError: