bareagent-cli 0.1.0__py3-none-any.whl

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 (121) hide show
  1. bareagent/__init__.py +10 -0
  2. bareagent/concurrency/__init__.py +6 -0
  3. bareagent/concurrency/background.py +97 -0
  4. bareagent/concurrency/notification.py +61 -0
  5. bareagent/concurrency/scheduler.py +136 -0
  6. bareagent/config.toml +299 -0
  7. bareagent/core/__init__.py +1 -0
  8. bareagent/core/config_paths.py +49 -0
  9. bareagent/core/context.py +127 -0
  10. bareagent/core/fileutil.py +103 -0
  11. bareagent/core/goal.py +214 -0
  12. bareagent/core/handlers/__init__.py +1 -0
  13. bareagent/core/handlers/bash.py +79 -0
  14. bareagent/core/handlers/file_edit.py +47 -0
  15. bareagent/core/handlers/file_read.py +270 -0
  16. bareagent/core/handlers/file_write.py +34 -0
  17. bareagent/core/handlers/glob_search.py +30 -0
  18. bareagent/core/handlers/goal.py +60 -0
  19. bareagent/core/handlers/grep_search.py +52 -0
  20. bareagent/core/handlers/memory.py +71 -0
  21. bareagent/core/handlers/plan.py +106 -0
  22. bareagent/core/handlers/search_utils.py +77 -0
  23. bareagent/core/handlers/skill.py +87 -0
  24. bareagent/core/handlers/subagent_send.py +70 -0
  25. bareagent/core/handlers/web_fetch.py +126 -0
  26. bareagent/core/handlers/web_search.py +165 -0
  27. bareagent/core/handlers/workflow.py +190 -0
  28. bareagent/core/loop.py +535 -0
  29. bareagent/core/retry.py +131 -0
  30. bareagent/core/sandbox.py +27 -0
  31. bareagent/core/schema.py +21 -0
  32. bareagent/core/tools.py +779 -0
  33. bareagent/core/workflow.py +517 -0
  34. bareagent/core/workflow_registry.py +219 -0
  35. bareagent/debug/__init__.py +0 -0
  36. bareagent/debug/interaction_log.py +263 -0
  37. bareagent/debug/viewer.html +1750 -0
  38. bareagent/debug/web_viewer.py +157 -0
  39. bareagent/hooks/__init__.py +32 -0
  40. bareagent/hooks/config.py +118 -0
  41. bareagent/hooks/engine.py +197 -0
  42. bareagent/hooks/errors.py +14 -0
  43. bareagent/hooks/events.py +22 -0
  44. bareagent/lsp/__init__.py +63 -0
  45. bareagent/lsp/config.py +134 -0
  46. bareagent/lsp/coord.py +118 -0
  47. bareagent/lsp/diagnostics.py +240 -0
  48. bareagent/lsp/errors.py +24 -0
  49. bareagent/lsp/manager.py +866 -0
  50. bareagent/lsp/tools.py +629 -0
  51. bareagent/lsp/workspace_edit.py +305 -0
  52. bareagent/main.py +4205 -0
  53. bareagent/mcp/__init__.py +69 -0
  54. bareagent/mcp/_sse.py +69 -0
  55. bareagent/mcp/client.py +341 -0
  56. bareagent/mcp/config.py +169 -0
  57. bareagent/mcp/errors.py +32 -0
  58. bareagent/mcp/manager.py +318 -0
  59. bareagent/mcp/protocol.py +187 -0
  60. bareagent/mcp/registry.py +557 -0
  61. bareagent/mcp/transport/__init__.py +15 -0
  62. bareagent/mcp/transport/base.py +149 -0
  63. bareagent/mcp/transport/http_legacy.py +192 -0
  64. bareagent/mcp/transport/http_streamable.py +217 -0
  65. bareagent/mcp/transport/stdio.py +202 -0
  66. bareagent/memory/__init__.py +1 -0
  67. bareagent/memory/compact.py +203 -0
  68. bareagent/memory/conversation_io.py +226 -0
  69. bareagent/memory/embedding.py +194 -0
  70. bareagent/memory/persistent.py +515 -0
  71. bareagent/memory/token_counter.py +67 -0
  72. bareagent/memory/token_tracker.py +262 -0
  73. bareagent/memory/transcript.py +100 -0
  74. bareagent/permission/__init__.py +1 -0
  75. bareagent/permission/guard.py +329 -0
  76. bareagent/permission/rules.py +19 -0
  77. bareagent/planning/__init__.py +19 -0
  78. bareagent/planning/agent_types.py +169 -0
  79. bareagent/planning/skill_gen.py +141 -0
  80. bareagent/planning/skill_store.py +173 -0
  81. bareagent/planning/skills.py +146 -0
  82. bareagent/planning/subagent.py +355 -0
  83. bareagent/planning/subagent_registry.py +77 -0
  84. bareagent/planning/tasks.py +348 -0
  85. bareagent/planning/todo.py +153 -0
  86. bareagent/planning/worktree.py +122 -0
  87. bareagent/provider/__init__.py +1 -0
  88. bareagent/provider/anthropic.py +348 -0
  89. bareagent/provider/base.py +136 -0
  90. bareagent/provider/factory.py +130 -0
  91. bareagent/provider/openai.py +881 -0
  92. bareagent/provider/presets.py +72 -0
  93. bareagent/provider/setup.py +356 -0
  94. bareagent/skills/.gitkeep +1 -0
  95. bareagent/skills/code-review/SKILL.md +68 -0
  96. bareagent/skills/git/SKILL.md +68 -0
  97. bareagent/skills/test/SKILL.md +70 -0
  98. bareagent/team/__init__.py +17 -0
  99. bareagent/team/autonomous.py +193 -0
  100. bareagent/team/mailbox.py +239 -0
  101. bareagent/team/manager.py +155 -0
  102. bareagent/team/protocols.py +129 -0
  103. bareagent/tracing/__init__.py +12 -0
  104. bareagent/tracing/_api.py +92 -0
  105. bareagent/tracing/_proxy.py +60 -0
  106. bareagent/tracing/composite.py +115 -0
  107. bareagent/tracing/json_file.py +115 -0
  108. bareagent/tracing/langfuse.py +139 -0
  109. bareagent/tracing/otel.py +107 -0
  110. bareagent/tracing/setup.py +85 -0
  111. bareagent/ui/__init__.py +24 -0
  112. bareagent/ui/console.py +167 -0
  113. bareagent/ui/prompt.py +78 -0
  114. bareagent/ui/protocol.py +24 -0
  115. bareagent/ui/stream.py +66 -0
  116. bareagent/ui/theme.py +240 -0
  117. bareagent_cli-0.1.0.dist-info/METADATA +331 -0
  118. bareagent_cli-0.1.0.dist-info/RECORD +121 -0
  119. bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
  120. bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  121. bareagent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,219 @@
1
+ """Session-scoped, thread-safe registry of workflow runs.
2
+
3
+ Backs two features layered on top of the deterministic ``workflow`` tool
4
+ (see ``src/core/workflow.py``): the ``/workflows`` panel (list / inspect runs)
5
+ and ``resume`` (reuse a prior run's node results). Unlike the pure
6
+ ``src/core/workflow.py`` engine, this module is *stateful and threaded*: a
7
+ background workflow runs in a daemon thread and updates its run here while the
8
+ REPL's main thread reads snapshots for the panel, so every method holds a lock.
9
+
10
+ Lifecycle mirrors :class:`bareagent.planning.subagent_registry.SubagentRegistry` and
11
+ ``spawned_agents``: in-memory, capped FIFO, cleared by the REPL on ``/new`` /
12
+ ``/resume`` / ``/import`` / ``/clear`` and kept across ``/compact``. On-disk
13
+ persistence (cross-restart resume) is intentionally out of scope.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import threading
19
+ import time
20
+ from dataclasses import dataclass, replace
21
+ from enum import Enum
22
+
23
+ from bareagent.core.fileutil import generate_random_id
24
+ from bareagent.core.workflow import NodeResult, NodeStatus, WorkflowSpec
25
+
26
+ _ID_PREFIX = "wf-"
27
+ DEFAULT_MAX_RUNS = 50
28
+
29
+
30
+ class RunStatus(Enum):
31
+ RUNNING = "running"
32
+ DONE = "done"
33
+ FAILED = "failed"
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class WorkflowRun:
38
+ """One workflow invocation's live (or terminal) state.
39
+
40
+ ``results`` is replaced key-by-key as nodes finish (whole ``NodeResult``
41
+ objects swapped in, never mutated in place), so a shallow copy taken under
42
+ the registry lock is a consistent snapshot. ``delivered`` dedups the async
43
+ result feedback: a finished background run is injected into the LLM exactly
44
+ once (see ``main.py:_drain_workflow_results``).
45
+ """
46
+
47
+ run_id: str
48
+ spec: WorkflowSpec
49
+ results: dict[str, NodeResult]
50
+ background: bool
51
+ token_budget: int
52
+ status: RunStatus = RunStatus.RUNNING
53
+ tokens_spent: int = 0
54
+ summary: str = ""
55
+ error: str = ""
56
+ started_at: float = 0.0
57
+ finished_at: float | None = None
58
+ delivered: bool = False
59
+
60
+ def counts(self) -> dict[str, int]:
61
+ """Tally node statuses for the panel (pending/running shown as 'running')."""
62
+ tally = {"done": 0, "failed": 0, "skipped": 0, "running": 0, "reused": 0}
63
+ for result in self.results.values():
64
+ if result.reused:
65
+ tally["reused"] += 1
66
+ elif result.status is NodeStatus.DONE:
67
+ tally["done"] += 1
68
+ elif result.status is NodeStatus.FAILED:
69
+ tally["failed"] += 1
70
+ elif result.status is NodeStatus.SKIPPED:
71
+ tally["skipped"] += 1
72
+ else:
73
+ tally["running"] += 1
74
+ return tally
75
+
76
+
77
+ class WorkflowRegistry:
78
+ """In-memory, thread-safe, FIFO-capped store of :class:`WorkflowRun`.
79
+
80
+ Holds at most ``max_runs`` runs; ``start`` evicts the oldest once over the
81
+ cap (preferring an already-finished run so an in-flight one keeps its panel
82
+ tracking). Updates to a run that has been evicted or cleared are silent
83
+ no-ops, which keeps a lingering background thread from crashing when the
84
+ session has already moved on.
85
+ """
86
+
87
+ def __init__(self, max_runs: int = DEFAULT_MAX_RUNS) -> None:
88
+ self._max = max_runs if max_runs > 0 else DEFAULT_MAX_RUNS
89
+ self._lock = threading.Lock()
90
+ self._runs: dict[str, WorkflowRun] = {}
91
+
92
+ def generate_id(self) -> str:
93
+ """Return a fresh, unused ``wf-<rand8>`` id."""
94
+ with self._lock:
95
+ while True:
96
+ candidate = _ID_PREFIX + generate_random_id(8)
97
+ if candidate not in self._runs:
98
+ return candidate
99
+
100
+ def start(
101
+ self,
102
+ run_id: str,
103
+ spec: WorkflowSpec,
104
+ *,
105
+ background: bool,
106
+ token_budget: int,
107
+ ) -> WorkflowRun:
108
+ """Register a new RUNNING run (all nodes PENDING) and evict over the cap."""
109
+ run = WorkflowRun(
110
+ run_id=run_id,
111
+ spec=spec,
112
+ results={
113
+ node.id: NodeResult(id=node.id, status=NodeStatus.PENDING) for node in spec.nodes
114
+ },
115
+ background=background,
116
+ token_budget=token_budget,
117
+ started_at=time.time(),
118
+ )
119
+ with self._lock:
120
+ self._runs[run_id] = run
121
+ self._evict_locked()
122
+ return run
123
+
124
+ def update_node(self, run_id: str, node_id: str, result: NodeResult) -> None:
125
+ """Replace one node's result (no-op if the run is gone)."""
126
+ with self._lock:
127
+ run = self._runs.get(run_id)
128
+ if run is not None and node_id in run.results:
129
+ run.results[node_id] = result
130
+
131
+ def set_tokens(self, run_id: str, tokens_spent: int) -> None:
132
+ with self._lock:
133
+ run = self._runs.get(run_id)
134
+ if run is not None:
135
+ run.tokens_spent = tokens_spent
136
+
137
+ def finish(
138
+ self,
139
+ run_id: str,
140
+ *,
141
+ summary: str,
142
+ tokens_spent: int,
143
+ status: RunStatus = RunStatus.DONE,
144
+ error: str = "",
145
+ ) -> None:
146
+ """Mark a run terminal and stage it for one-shot async delivery."""
147
+ with self._lock:
148
+ run = self._runs.get(run_id)
149
+ if run is None:
150
+ return
151
+ run.status = status
152
+ run.summary = summary
153
+ run.error = error
154
+ run.tokens_spent = tokens_spent
155
+ run.finished_at = time.time()
156
+ run.delivered = False
157
+
158
+ def get_for_resume(self, run_id: str) -> tuple[WorkflowSpec, dict[str, NodeResult]] | None:
159
+ """Return ``(spec, results copy)`` of a prior run for resume, or None."""
160
+ with self._lock:
161
+ run = self._runs.get(run_id)
162
+ if run is None:
163
+ return None
164
+ return run.spec, dict(run.results)
165
+
166
+ def snapshot(self) -> list[WorkflowRun]:
167
+ """Return consistent copies of every run, newest last (panel list)."""
168
+ with self._lock:
169
+ return [self._copy_locked(run) for run in self._runs.values()]
170
+
171
+ def get(self, run_id: str) -> WorkflowRun | None:
172
+ """Return a consistent copy of one run, or None (panel detail)."""
173
+ with self._lock:
174
+ run = self._runs.get(run_id)
175
+ return self._copy_locked(run) if run is not None else None
176
+
177
+ def take_undelivered(self) -> list[WorkflowRun]:
178
+ """Return copies of finished, not-yet-delivered runs, marking them delivered.
179
+
180
+ Used by the REPL drain to inject a finished background workflow's full
181
+ summary into the LLM exactly once.
182
+ """
183
+ out: list[WorkflowRun] = []
184
+ with self._lock:
185
+ for run in self._runs.values():
186
+ if run.status is not RunStatus.RUNNING and not run.delivered:
187
+ run.delivered = True
188
+ out.append(self._copy_locked(run))
189
+ return out
190
+
191
+ def clear_finished(self) -> int:
192
+ """Drop every non-RUNNING run; return how many were removed."""
193
+ with self._lock:
194
+ finished = [rid for rid, run in self._runs.items() if run.status is RunStatus.RUNNING]
195
+ removed = len(self._runs) - len(finished)
196
+ self._runs = {rid: self._runs[rid] for rid in finished}
197
+ return removed
198
+
199
+ def clear(self) -> None:
200
+ with self._lock:
201
+ self._runs.clear()
202
+
203
+ def __len__(self) -> int:
204
+ with self._lock:
205
+ return len(self._runs)
206
+
207
+ def _evict_locked(self) -> None:
208
+ """Trim to the cap, evicting oldest finished runs first (lock held)."""
209
+ while len(self._runs) > self._max:
210
+ victim = next(
211
+ (rid for rid, run in self._runs.items() if run.status is not RunStatus.RUNNING),
212
+ next(iter(self._runs)),
213
+ )
214
+ del self._runs[victim]
215
+
216
+ @staticmethod
217
+ def _copy_locked(run: WorkflowRun) -> WorkflowRun:
218
+ """Snapshot a run with a copied results dict (lock held by caller)."""
219
+ return replace(run, results=dict(run.results))
File without changes
@@ -0,0 +1,263 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import queue
5
+ import threading
6
+ import time
7
+ from pathlib import Path, PurePosixPath, PureWindowsPath
8
+ from typing import Any
9
+
10
+ _DEFAULT_EVENT_QUEUE_SIZE = 256
11
+
12
+
13
+ class InteractionLogger:
14
+ """Persist complete LLM request/response payloads for a session."""
15
+
16
+ def __init__(
17
+ self,
18
+ log_dir: str | Path = ".logs",
19
+ session_id: str = "default",
20
+ *,
21
+ pretty: bool = True,
22
+ ) -> None:
23
+ self._log_dir = Path(log_dir)
24
+ self._session_id = self._validate_session_id(session_id)
25
+ self._pretty = pretty
26
+ self._seq = 0
27
+ self._session_dir: Path | None = None
28
+ self._event_lock = threading.Lock()
29
+ self._event_subscribers: set[queue.Queue[dict[str, Any]]] = set()
30
+ self._legacy_event_queue: queue.Queue[dict[str, Any]] | None = None
31
+
32
+ @property
33
+ def event_queue(self) -> queue.Queue[dict[str, Any]]:
34
+ if self._legacy_event_queue is None:
35
+ self._legacy_event_queue = self.subscribe_events()
36
+ return self._legacy_event_queue
37
+
38
+ @property
39
+ def session_id(self) -> str:
40
+ return self._session_id
41
+
42
+ @session_id.setter
43
+ def session_id(self, value: str) -> None:
44
+ self._session_id = self._validate_session_id(value)
45
+ self._session_dir = None
46
+ self._seq = 0
47
+
48
+ def log_request(
49
+ self,
50
+ messages: list[dict[str, Any]],
51
+ tools: list[dict[str, Any]],
52
+ *,
53
+ provider_info: dict[str, Any] | None = None,
54
+ ) -> int:
55
+ self._ensure_session_dir()
56
+ seq = self._seq
57
+ payload = {
58
+ "seq": seq,
59
+ "type": "request",
60
+ "timestamp": time.time(),
61
+ "provider": provider_info or {},
62
+ "messages": messages,
63
+ "tools": tools,
64
+ "message_count": len(messages),
65
+ "tool_count": len(tools),
66
+ }
67
+ self._write(f"{seq:03d}_request.json", payload)
68
+ self._seq = seq + 1
69
+ self._push_event("request", seq, payload)
70
+ return seq
71
+
72
+ def log_response(
73
+ self,
74
+ seq: int,
75
+ *,
76
+ text: str = "",
77
+ thinking: str = "",
78
+ tool_calls: list[dict[str, Any]] | None = None,
79
+ input_tokens: int = 0,
80
+ output_tokens: int = 0,
81
+ duration_ms: float = 0,
82
+ error: str | None = None,
83
+ ) -> None:
84
+ payload: dict[str, Any] = {
85
+ "seq": seq,
86
+ "type": "response",
87
+ "timestamp": time.time(),
88
+ "text": text,
89
+ "thinking": thinking,
90
+ "tool_calls": list(tool_calls or []),
91
+ "input_tokens": input_tokens,
92
+ "output_tokens": output_tokens,
93
+ "duration_ms": round(duration_ms, 2),
94
+ }
95
+ if error is not None:
96
+ payload["error"] = error
97
+ try:
98
+ self._write(f"{seq:03d}_response.json", payload)
99
+ finally:
100
+ self._seq = max(self._seq, seq + 1)
101
+ self._push_event("response", seq, payload)
102
+
103
+ def list_sessions(self) -> list[str]:
104
+ if not self._log_dir.is_dir():
105
+ return []
106
+ return sorted(path.name for path in self._log_dir.iterdir() if path.is_dir())
107
+
108
+ def list_interactions(self, session_id: str) -> list[dict[str, Any]]:
109
+ session_dir = self._session_path(session_id)
110
+ if not session_dir.is_dir():
111
+ return []
112
+
113
+ interactions: list[dict[str, Any]] = []
114
+ for request_path in sorted(
115
+ session_dir.glob("*_request.json"),
116
+ key=self._path_seq,
117
+ ):
118
+ seq = self._path_seq(request_path)
119
+ if seq < 0:
120
+ continue
121
+
122
+ request_data = self._read_json(request_path) or {}
123
+ entry: dict[str, Any] = {
124
+ "seq": seq,
125
+ "timestamp": request_data.get("timestamp"),
126
+ "message_count": request_data.get("message_count", 0),
127
+ "tool_count": request_data.get("tool_count", 0),
128
+ }
129
+
130
+ response_data = self._read_json(session_dir / f"{seq:03d}_response.json")
131
+ if response_data is not None:
132
+ entry.update(
133
+ {
134
+ "input_tokens": response_data.get("input_tokens", 0),
135
+ "output_tokens": response_data.get("output_tokens", 0),
136
+ "duration_ms": response_data.get("duration_ms", 0),
137
+ "tool_call_count": len(response_data.get("tool_calls", [])),
138
+ "has_error": "error" in response_data,
139
+ }
140
+ )
141
+
142
+ interactions.append(entry)
143
+
144
+ return interactions
145
+
146
+ def get_interaction(self, session_id: str, seq: int) -> dict[str, Any]:
147
+ session_dir = self._session_path(session_id)
148
+ return {
149
+ "seq": seq,
150
+ "request": self._read_json(session_dir / f"{seq:03d}_request.json"),
151
+ "response": self._read_json(session_dir / f"{seq:03d}_response.json"),
152
+ }
153
+
154
+ def subscribe_events(
155
+ self,
156
+ *,
157
+ maxsize: int = _DEFAULT_EVENT_QUEUE_SIZE,
158
+ ) -> queue.Queue[dict[str, Any]]:
159
+ event_queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=maxsize)
160
+ with self._event_lock:
161
+ self._event_subscribers.add(event_queue)
162
+ return event_queue
163
+
164
+ def unsubscribe_events(self, event_queue: queue.Queue[dict[str, Any]]) -> None:
165
+ with self._event_lock:
166
+ self._event_subscribers.discard(event_queue)
167
+ if event_queue is self._legacy_event_queue:
168
+ self._legacy_event_queue = None
169
+
170
+ def _ensure_session_dir(self) -> Path:
171
+ if self._session_dir is None:
172
+ self._session_dir = self._session_path(self._session_id)
173
+ self._session_dir.mkdir(parents=True, exist_ok=True)
174
+ self._seq = self._discover_next_seq(self._session_dir)
175
+ return self._session_dir
176
+
177
+ def _session_path(self, session_id: str) -> Path:
178
+ return self._log_dir / self._validate_session_id(session_id)
179
+
180
+ def _validate_session_id(self, session_id: str) -> str:
181
+ value = str(session_id)
182
+ if value in {"", ".", ".."}:
183
+ raise ValueError("Session ID must be a single relative path segment.")
184
+ if "/" in value or "\\" in value:
185
+ raise ValueError("Session ID must not contain path separators.")
186
+
187
+ posix_path = PurePosixPath(value)
188
+ windows_path = PureWindowsPath(value)
189
+ if posix_path.is_absolute() or windows_path.is_absolute():
190
+ raise ValueError("Session ID must not be an absolute path.")
191
+ if posix_path.anchor or windows_path.anchor:
192
+ raise ValueError("Session ID must not include a path anchor.")
193
+ if len(posix_path.parts) != 1 or len(windows_path.parts) != 1:
194
+ raise ValueError("Session ID must be a single path segment.")
195
+ return value
196
+
197
+ def _discover_next_seq(self, session_dir: Path) -> int:
198
+ max_seq = -1
199
+ for path in session_dir.glob("*_*.json"):
200
+ max_seq = max(max_seq, self._path_seq(path))
201
+ return max_seq + 1
202
+
203
+ def _path_seq(self, path: Path) -> int:
204
+ seq_text = path.stem.split("_", 1)[0]
205
+ return int(seq_text) if seq_text.isdigit() else -1
206
+
207
+ def _read_json(self, path: Path) -> dict[str, Any] | None:
208
+ if not path.is_file():
209
+ return None
210
+ try:
211
+ data = json.loads(path.read_text(encoding="utf-8"))
212
+ except (OSError, json.JSONDecodeError):
213
+ return None
214
+ if isinstance(data, dict):
215
+ return data
216
+ return None
217
+
218
+ def _write(self, filename: str, payload: dict[str, Any]) -> None:
219
+ session_dir = self._ensure_session_dir()
220
+ path = session_dir / filename
221
+ path.write_text(
222
+ json.dumps(
223
+ payload,
224
+ ensure_ascii=False,
225
+ indent=2 if self._pretty else None,
226
+ default=str,
227
+ ),
228
+ encoding="utf-8",
229
+ )
230
+
231
+ def _push_event(self, event_type: str, seq: int, payload: dict[str, Any]) -> None:
232
+ event = {
233
+ "event": event_type,
234
+ "session_id": self._session_id,
235
+ "seq": seq,
236
+ "timestamp": payload.get("timestamp"),
237
+ }
238
+ with self._event_lock:
239
+ subscribers = tuple(self._event_subscribers)
240
+
241
+ for event_queue in subscribers:
242
+ self._publish_event(event_queue, event)
243
+
244
+ def _publish_event(
245
+ self,
246
+ event_queue: queue.Queue[dict[str, Any]],
247
+ event: dict[str, Any],
248
+ ) -> None:
249
+ try:
250
+ event_queue.put_nowait(event)
251
+ return
252
+ except queue.Full:
253
+ pass
254
+
255
+ try:
256
+ event_queue.get_nowait()
257
+ except queue.Empty:
258
+ return
259
+
260
+ try:
261
+ event_queue.put_nowait(event)
262
+ except queue.Full:
263
+ return