@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.
- package/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- package/server/workspace_git.py +545 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Read native Claude goal attachments without interpreting conversation text.
|
|
2
|
+
|
|
3
|
+
Call refresh from the existing provider-history/activity path. This module owns
|
|
4
|
+
no watcher, timer, provider command, or transcript mutation.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections import deque, OrderedDict
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
from uuid import UUID
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
MAX_GOAL_RECORD_BYTES = 128 * 1024
|
|
19
|
+
MAX_GOAL_SCAN_BYTES = 4 * 1024 * 1024
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_claude_synthetic_no_response(event: Any) -> bool:
|
|
23
|
+
if not isinstance(event, dict) or event.get("type") != "assistant" or event.get("isSidechain") is True:
|
|
24
|
+
return False
|
|
25
|
+
message = event.get("message")
|
|
26
|
+
return bool(isinstance(message, dict) and message.get("model") == "<synthetic>"
|
|
27
|
+
and message.get("role") == "assistant"
|
|
28
|
+
and message.get("content") == [{"type": "text", "text": "No response requested."}]
|
|
29
|
+
and isinstance(message.get("usage"), dict)
|
|
30
|
+
and message["usage"].get("output_tokens") == 0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ClaudeGoalHistoryNormalizer:
|
|
34
|
+
"""Normalize only commands linked to native goal attachments, never quotes."""
|
|
35
|
+
_command = re.compile(r"\A<command-name>/goal</command-name>\s*<command-message>goal</command-message>\s*<command-args>(.*)</command-args>\Z", re.DOTALL)
|
|
36
|
+
|
|
37
|
+
def __init__(self):
|
|
38
|
+
self._parents: OrderedDict[str, dict] = OrderedDict()
|
|
39
|
+
|
|
40
|
+
def seed(self, path: Path, offset: int) -> None:
|
|
41
|
+
if offset <= 0:
|
|
42
|
+
return
|
|
43
|
+
try:
|
|
44
|
+
with path.open("rb") as stream:
|
|
45
|
+
start = max(0, offset - 65536)
|
|
46
|
+
stream.seek(start)
|
|
47
|
+
region = stream.read(offset - start)
|
|
48
|
+
except OSError:
|
|
49
|
+
return
|
|
50
|
+
if start:
|
|
51
|
+
region = region.partition(b"\n")[2]
|
|
52
|
+
for line in region.splitlines():
|
|
53
|
+
try:
|
|
54
|
+
self.consume(json.loads(line))
|
|
55
|
+
except (ValueError, RecursionError):
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
def consume(self, event: Any) -> Any:
|
|
59
|
+
if not isinstance(event, dict) or event.get("isSidechain") is True:
|
|
60
|
+
return event
|
|
61
|
+
parent = self._parents.get(event.get("parentUuid"))
|
|
62
|
+
same_session = bool(parent and parent.get("sessionId") == event.get("sessionId"))
|
|
63
|
+
message = event.get("message")
|
|
64
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
65
|
+
attachment = event.get("attachment")
|
|
66
|
+
if (isinstance(attachment, dict) and attachment.get("type") == "goal_status"
|
|
67
|
+
and attachment.get("sentinel") is True and type(attachment.get("met")) is bool):
|
|
68
|
+
self._remember(event)
|
|
69
|
+
elif (same_session and event.get("type") == "user" and event.get("isMeta") is True
|
|
70
|
+
and isinstance(content, str) and content.startswith("<local-command-caveat>")
|
|
71
|
+
and parent.get("attachment", {}).get("met") is True):
|
|
72
|
+
self._remember({**event, "attachment": parent["attachment"]})
|
|
73
|
+
elif (same_session and event.get("type") == "user" and isinstance(message, dict)
|
|
74
|
+
and message.get("role") == "user" and isinstance(content, str)):
|
|
75
|
+
match = self._command.fullmatch(content)
|
|
76
|
+
native = parent.get("attachment", {})
|
|
77
|
+
if match and ((native.get("met") is False and match[1] == native.get("condition"))
|
|
78
|
+
or (native.get("met") is True and match[1] == "clear"
|
|
79
|
+
and parent.get("isMeta") is True
|
|
80
|
+
and parent.get("promptId") == event.get("promptId"))):
|
|
81
|
+
return {**event, "message": {**message, "content": "/goal " + match[1]}}
|
|
82
|
+
return event
|
|
83
|
+
|
|
84
|
+
def _remember(self, event: dict) -> None:
|
|
85
|
+
identity = event.get("uuid")
|
|
86
|
+
if not isinstance(identity, str) or not isinstance(event.get("sessionId"), str):
|
|
87
|
+
return
|
|
88
|
+
native = event.get("attachment", {})
|
|
89
|
+
condition = native.get("condition")
|
|
90
|
+
if not isinstance(condition, str) or len(condition) > 16_384:
|
|
91
|
+
return
|
|
92
|
+
self._parents[identity] = {key: event.get(key) for key in ("sessionId", "promptId", "isMeta")}
|
|
93
|
+
self._parents[identity]["attachment"] = {
|
|
94
|
+
"met": native.get("met"), "condition": condition,
|
|
95
|
+
}
|
|
96
|
+
while len(self._parents) > 32:
|
|
97
|
+
self._parents.popitem(last=False)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _timestamp_ms(value: Any) -> int | None:
|
|
101
|
+
if not isinstance(value, str) or len(value) > 64:
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
105
|
+
return int(parsed.timestamp() * 1000) if parsed.tzinfo else None
|
|
106
|
+
except (ValueError, OverflowError, OSError):
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _counter(value: Any) -> int | None:
|
|
111
|
+
return value if type(value) is int and 0 <= value <= 2**53 - 1 else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ClaudeGoalProjection:
|
|
115
|
+
"""One provider session's latest goal and bounded incremental file cursor.
|
|
116
|
+
|
|
117
|
+
A native set sentinel establishes the goal. Evaluator and clear records
|
|
118
|
+
update that exact condition; unrelated sessions/sidechains cannot establish
|
|
119
|
+
or finish it. A fork starts empty until its own native set evidence arrives.
|
|
120
|
+
No failure, pause, or success is inferred from a result/assistant message.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(self, provider_session_id: str):
|
|
124
|
+
self.reset(provider_session_id)
|
|
125
|
+
|
|
126
|
+
def reset(self, provider_session_id: str) -> None:
|
|
127
|
+
self.provider_session_id = str(provider_session_id)
|
|
128
|
+
self.goal: dict[str, Any] | None = None
|
|
129
|
+
self.offset = 0
|
|
130
|
+
self.caught_up = False
|
|
131
|
+
self._identity: tuple[str, int, int] | None = None
|
|
132
|
+
self._tail = b""
|
|
133
|
+
self._anchor = b""
|
|
134
|
+
self._discard_line = False
|
|
135
|
+
self._seen: deque[str] = deque(maxlen=256)
|
|
136
|
+
self._last_timestamp = -1
|
|
137
|
+
|
|
138
|
+
def consume(self, event: Any) -> bool:
|
|
139
|
+
"""Fold one native JSONL record; return whether visible goal changed."""
|
|
140
|
+
if not isinstance(event, dict) or (
|
|
141
|
+
event.get("type") != "attachment"
|
|
142
|
+
or event.get("sessionId") != self.provider_session_id
|
|
143
|
+
or event.get("isSidechain") is not False
|
|
144
|
+
):
|
|
145
|
+
return False
|
|
146
|
+
attachment = event.get("attachment")
|
|
147
|
+
if not isinstance(attachment, dict) or attachment.get("type") != "goal_status":
|
|
148
|
+
return False
|
|
149
|
+
event_id = event.get("uuid")
|
|
150
|
+
if not isinstance(event_id, str) or len(event_id) != 36:
|
|
151
|
+
return False
|
|
152
|
+
try:
|
|
153
|
+
UUID(event_id)
|
|
154
|
+
except ValueError:
|
|
155
|
+
return False
|
|
156
|
+
timestamp = _timestamp_ms(event.get("timestamp"))
|
|
157
|
+
condition = attachment.get("condition")
|
|
158
|
+
met = attachment.get("met")
|
|
159
|
+
sentinel = attachment.get("sentinel", False)
|
|
160
|
+
if (
|
|
161
|
+
timestamp is None or timestamp < self._last_timestamp
|
|
162
|
+
or event_id in self._seen
|
|
163
|
+
or not isinstance(condition, str) or not condition.strip()
|
|
164
|
+
or len(condition) > 16_384
|
|
165
|
+
or type(met) is not bool or type(sentinel) is not bool
|
|
166
|
+
):
|
|
167
|
+
return False
|
|
168
|
+
previous = self.goal
|
|
169
|
+
if sentinel and not met:
|
|
170
|
+
goal = {"condition": condition, "status": "active", "set_at": timestamp}
|
|
171
|
+
else:
|
|
172
|
+
if previous is None or previous["condition"] != condition:
|
|
173
|
+
return False
|
|
174
|
+
if not sentinel and previous["status"] != "active":
|
|
175
|
+
return False
|
|
176
|
+
goal = dict(previous)
|
|
177
|
+
goal["status"] = "cleared" if sentinel else "achieved" if met else "active"
|
|
178
|
+
if sentinel:
|
|
179
|
+
for field in ("iterations", "duration_ms", "tokens"):
|
|
180
|
+
goal.pop(field, None)
|
|
181
|
+
if not sentinel:
|
|
182
|
+
reason = attachment.get("reason")
|
|
183
|
+
if isinstance(reason, str) and reason:
|
|
184
|
+
goal["last_reason"] = reason[:8192]
|
|
185
|
+
# Resuming the native session can reset its counters. The
|
|
186
|
+
# number of historical evaluator rows is not a native total.
|
|
187
|
+
for native, public in (("iterations", "iterations"), ("durationMs", "duration_ms"), ("tokens", "tokens")):
|
|
188
|
+
value = _counter(attachment.get(native))
|
|
189
|
+
if value is not None:
|
|
190
|
+
goal[public] = value
|
|
191
|
+
else:
|
|
192
|
+
goal.pop(public, None)
|
|
193
|
+
self._seen.append(event_id)
|
|
194
|
+
self._last_timestamp = timestamp
|
|
195
|
+
self.goal = goal
|
|
196
|
+
return goal != previous
|
|
197
|
+
|
|
198
|
+
def refresh(
|
|
199
|
+
self, path: str | Path, *, provider_session_id: str | None = None,
|
|
200
|
+
max_bytes: int = MAX_GOAL_SCAN_BYTES,
|
|
201
|
+
) -> bool:
|
|
202
|
+
"""Read at most max_bytes; later activity can continue an unfinished scan.
|
|
203
|
+
|
|
204
|
+
Partial lines are retained, oversized lines skipped, and replacement,
|
|
205
|
+
truncation or provider changes reset both projection and cursor. A
|
|
206
|
+
temporarily missing/unreadable file keeps the last observed native state.
|
|
207
|
+
The caller should run file I/O off the server's event loop.
|
|
208
|
+
"""
|
|
209
|
+
previous = self.goal
|
|
210
|
+
if provider_session_id is not None and provider_session_id != self.provider_session_id:
|
|
211
|
+
self.reset(provider_session_id)
|
|
212
|
+
budget = max(1, min(int(max_bytes), MAX_GOAL_SCAN_BYTES))
|
|
213
|
+
try:
|
|
214
|
+
with Path(path).open("rb") as stream:
|
|
215
|
+
stat = os.fstat(stream.fileno())
|
|
216
|
+
identity = (str(Path(path).absolute()), stat.st_dev, stat.st_ino)
|
|
217
|
+
stream.seek(max(0, self.offset - len(self._anchor)))
|
|
218
|
+
anchor_matches = stream.read(len(self._anchor)) == self._anchor
|
|
219
|
+
if self._identity != identity or stat.st_size < self.offset or not anchor_matches:
|
|
220
|
+
self.reset(self.provider_session_id)
|
|
221
|
+
self._identity = identity
|
|
222
|
+
stream.seek(self.offset)
|
|
223
|
+
chunk = stream.read(budget)
|
|
224
|
+
self.offset += len(chunk)
|
|
225
|
+
self._anchor = (self._anchor + chunk)[-64:]
|
|
226
|
+
self.caught_up = self.offset >= stat.st_size
|
|
227
|
+
except OSError:
|
|
228
|
+
self.caught_up = False
|
|
229
|
+
return previous != self.goal
|
|
230
|
+
lines = (self._tail + chunk).split(b"\n")
|
|
231
|
+
self._tail = lines.pop()
|
|
232
|
+
for line in lines:
|
|
233
|
+
if self._discard_line:
|
|
234
|
+
self._discard_line = False
|
|
235
|
+
continue
|
|
236
|
+
if len(line) > MAX_GOAL_RECORD_BYTES or b'"goal_status"' not in line:
|
|
237
|
+
continue
|
|
238
|
+
try:
|
|
239
|
+
self.consume(json.loads(line))
|
|
240
|
+
except (ValueError, RecursionError):
|
|
241
|
+
continue
|
|
242
|
+
if len(self._tail) > MAX_GOAL_RECORD_BYTES:
|
|
243
|
+
self._tail = b""
|
|
244
|
+
self._discard_line = True
|
|
245
|
+
return previous != self.goal
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Pure, bounded Claude source-lineage classification. Never edits transcripts.
|
|
2
|
+
|
|
3
|
+
The marker's wording is not evidence of a human Stop action. A synthetic user
|
|
4
|
+
record must belong to an already established prompt, not begin a new prompt.
|
|
5
|
+
Only source identifiers are carried between byte-cursor reads; never chat text.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
INTERRUPTION_MARKERS = frozenset({
|
|
14
|
+
"[Request interrupted by user]",
|
|
15
|
+
"[Request interrupted by user for tool use]",
|
|
16
|
+
})
|
|
17
|
+
_UUID = re.compile(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\Z")
|
|
18
|
+
_TIME = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)\Z")
|
|
19
|
+
_MAX_BRANCH_EVENTS = 128
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _uuid(value) -> str | None:
|
|
23
|
+
return value if isinstance(value, str) and _UUID.fullmatch(value) else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _timestamp(value) -> str | None:
|
|
27
|
+
if not isinstance(value, str) or not _TIME.fullmatch(value):
|
|
28
|
+
return None
|
|
29
|
+
try:
|
|
30
|
+
return value if datetime.fromisoformat(value.replace("Z", "+00:00")).tzinfo else None
|
|
31
|
+
except ValueError:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalize_claude_interruption_context(value, provider_session_id=None) -> dict:
|
|
36
|
+
"""Allowlist a tiny initialized cursor state, including conservative empties."""
|
|
37
|
+
empty = {"version": 1}
|
|
38
|
+
if not isinstance(value, dict) or value.get("version") != 1:
|
|
39
|
+
return empty
|
|
40
|
+
keys = ("session_id", "prompt_id", "anchor_event_id", "last_event_id")
|
|
41
|
+
if any(not _uuid(value.get(key)) for key in keys):
|
|
42
|
+
return empty
|
|
43
|
+
if provider_session_id and value["session_id"] != provider_session_id:
|
|
44
|
+
return empty
|
|
45
|
+
context = {"version": 1, **{key: value[key] for key in keys}}
|
|
46
|
+
branch_ids = value.get("branch_event_ids")
|
|
47
|
+
if branch_ids is not None:
|
|
48
|
+
if (not isinstance(branch_ids, list) or not 1 <= len(branch_ids) <= _MAX_BRANCH_EVENTS
|
|
49
|
+
or any(not _uuid(item) for item in branch_ids)
|
|
50
|
+
or context["last_event_id"] not in branch_ids):
|
|
51
|
+
return empty
|
|
52
|
+
context["branch_event_ids"] = list(dict.fromkeys(branch_ids))
|
|
53
|
+
return context
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ClaudeInterruptionTracker:
|
|
57
|
+
def __init__(self, initial_context=None):
|
|
58
|
+
self._context = normalize_claude_interruption_context(initial_context)
|
|
59
|
+
|
|
60
|
+
def export_context(self) -> dict:
|
|
61
|
+
return normalize_claude_interruption_context(self._context)
|
|
62
|
+
|
|
63
|
+
def consume(self, event) -> dict | None:
|
|
64
|
+
if not isinstance(event, dict):
|
|
65
|
+
self._context = {"version": 1}
|
|
66
|
+
return None
|
|
67
|
+
if event.get("isSidechain") is True:
|
|
68
|
+
return None
|
|
69
|
+
event_id = _uuid(event.get("uuid"))
|
|
70
|
+
# Non-message queue bookkeeping has no UUID and is outside the lineage.
|
|
71
|
+
if event_id is None:
|
|
72
|
+
if not isinstance(event.get("type"), str) or event.get("type") in ("user", "assistant", "attachment"):
|
|
73
|
+
self._context = {"version": 1}
|
|
74
|
+
return None
|
|
75
|
+
session_id = _uuid(event.get("sessionId"))
|
|
76
|
+
parent_id = _uuid(event.get("parentUuid"))
|
|
77
|
+
prompt_id = _uuid(event.get("promptId"))
|
|
78
|
+
timestamp = _timestamp(event.get("timestamp"))
|
|
79
|
+
message = event.get("message")
|
|
80
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
81
|
+
blocks = content if isinstance(content, list) else []
|
|
82
|
+
marker = (
|
|
83
|
+
event.get("type") == "user"
|
|
84
|
+
and isinstance(message, dict) and message.get("role") == "user"
|
|
85
|
+
and len(blocks) == 1 and isinstance(blocks[0], dict)
|
|
86
|
+
and blocks[0].get("type") == "text"
|
|
87
|
+
and isinstance(blocks[0].get("text"), str)
|
|
88
|
+
and blocks[0]["text"] in INTERRUPTION_MARKERS
|
|
89
|
+
)
|
|
90
|
+
context = self._context
|
|
91
|
+
# Parallel tool results can be siblings of the same assistant record.
|
|
92
|
+
# Keep their proven parents across incremental cursor reads; immediate
|
|
93
|
+
# adjacency alone is not Claude's transcript ancestry contract.
|
|
94
|
+
branch_ids = context.get("branch_event_ids", [context.get("last_event_id")])
|
|
95
|
+
continued = (
|
|
96
|
+
session_id is not None and session_id == context.get("session_id")
|
|
97
|
+
and parent_id is not None and parent_id in branch_ids
|
|
98
|
+
and (prompt_id is None or prompt_id == context.get("prompt_id"))
|
|
99
|
+
)
|
|
100
|
+
origin = None
|
|
101
|
+
if marker and session_id and timestamp and (
|
|
102
|
+
event.get("isMeta") is True
|
|
103
|
+
or (continued and prompt_id is not None and prompt_id == context.get("prompt_id")
|
|
104
|
+
and event_id != context.get("anchor_event_id")
|
|
105
|
+
and parent_id != context.get("anchor_event_id"))
|
|
106
|
+
):
|
|
107
|
+
origin = {
|
|
108
|
+
"provider": "claude", "kind": "interruption", "cause": "unknown",
|
|
109
|
+
"event_id": event_id, "session_id": session_id, "timestamp": timestamp,
|
|
110
|
+
}
|
|
111
|
+
if parent_id:
|
|
112
|
+
origin["parent_event_id"] = parent_id
|
|
113
|
+
if prompt_id:
|
|
114
|
+
origin["prompt_id"] = prompt_id
|
|
115
|
+
|
|
116
|
+
has_text = isinstance(content, str) and bool(content.strip()) or any(
|
|
117
|
+
isinstance(block, dict) and block.get("type") == "text"
|
|
118
|
+
and isinstance(block.get("text"), str) and bool(block["text"].strip())
|
|
119
|
+
for block in blocks
|
|
120
|
+
)
|
|
121
|
+
tool_result = any(isinstance(block, dict) and block.get("type") == "tool_result" for block in blocks)
|
|
122
|
+
real_prompt = (
|
|
123
|
+
event.get("type") == "user" and isinstance(message, dict)
|
|
124
|
+
and message.get("role") == "user" and has_text and not tool_result
|
|
125
|
+
and not marker and event.get("isMeta") is not True
|
|
126
|
+
and session_id and prompt_id and timestamp
|
|
127
|
+
)
|
|
128
|
+
if real_prompt:
|
|
129
|
+
self._context = {"version": 1, "session_id": session_id, "prompt_id": prompt_id,
|
|
130
|
+
"anchor_event_id": event_id, "last_event_id": event_id,
|
|
131
|
+
"branch_event_ids": [event_id]}
|
|
132
|
+
elif continued:
|
|
133
|
+
self._context = {**context, "last_event_id": event_id,
|
|
134
|
+
"branch_event_ids": [*branch_ids, event_id][-_MAX_BRANCH_EVENTS:]}
|
|
135
|
+
else:
|
|
136
|
+
self._context = {"version": 1}
|
|
137
|
+
return origin
|