@trac3er/oh-my-god 2.0.0-beta.2 → 2.1.0-alpha

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 (47) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/OMG-setup.sh +33 -0
  4. package/README.md +43 -0
  5. package/claude_experimental/__init__.py +27 -0
  6. package/claude_experimental/_compat.py +12 -0
  7. package/claude_experimental/_degradation.py +210 -0
  8. package/claude_experimental/_flags.py +35 -0
  9. package/claude_experimental/_lifecycle.py +122 -0
  10. package/claude_experimental/integration/__init__.py +16 -0
  11. package/claude_experimental/integration/_placeholder.py +7 -0
  12. package/claude_experimental/integration/autotuner.py +182 -0
  13. package/claude_experimental/integration/checkpoints.py +327 -0
  14. package/claude_experimental/integration/experiments.py +302 -0
  15. package/claude_experimental/integration/openapi_gen.py +428 -0
  16. package/claude_experimental/integration/streaming.py +131 -0
  17. package/claude_experimental/integration/telemetry.py +287 -0
  18. package/claude_experimental/memory/__init__.py +16 -0
  19. package/claude_experimental/memory/_placeholder.py +7 -0
  20. package/claude_experimental/memory/api.py +434 -0
  21. package/claude_experimental/memory/augmented_generation.py +142 -0
  22. package/claude_experimental/memory/episodic.py +190 -0
  23. package/claude_experimental/memory/failure_learning.py +183 -0
  24. package/claude_experimental/memory/migrate.py +169 -0
  25. package/claude_experimental/memory/procedural.py +212 -0
  26. package/claude_experimental/memory/semantic.py +376 -0
  27. package/claude_experimental/memory/store.py +310 -0
  28. package/claude_experimental/parallel/__init__.py +17 -0
  29. package/claude_experimental/parallel/_placeholder.py +7 -0
  30. package/claude_experimental/parallel/aggregation.py +131 -0
  31. package/claude_experimental/parallel/api.py +244 -0
  32. package/claude_experimental/parallel/executor.py +110 -0
  33. package/claude_experimental/parallel/ralph_bridge.py +164 -0
  34. package/claude_experimental/parallel/sandbox.py +314 -0
  35. package/claude_experimental/parallel/scaling.py +171 -0
  36. package/claude_experimental/parallel/ultraworker.py +285 -0
  37. package/claude_experimental/patterns/__init__.py +16 -0
  38. package/claude_experimental/patterns/_placeholder.py +7 -0
  39. package/claude_experimental/patterns/antipatterns.py +454 -0
  40. package/claude_experimental/patterns/api.py +196 -0
  41. package/claude_experimental/patterns/extractor.py +163 -0
  42. package/claude_experimental/patterns/mining.py +219 -0
  43. package/claude_experimental/patterns/refactoring.py +234 -0
  44. package/hooks/_common.py +3 -1
  45. package/package.json +1 -1
  46. package/runtime/subagent_dispatcher.py +109 -9
  47. package/settings.json +6 -1
@@ -0,0 +1,428 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import json
5
+ import re
6
+ from dataclasses import dataclass
7
+ from importlib import import_module
8
+ from pathlib import Path
9
+ from typing import Callable, Protocol, TypeAlias, cast
10
+ from urllib import parse, request
11
+ from urllib.error import HTTPError
12
+
13
+ JSONScalar: TypeAlias = str | int | float | bool | None
14
+ JSONValue: TypeAlias = JSONScalar | dict[str, "JSONValue"] | list["JSONValue"]
15
+ JSONDict: TypeAlias = dict[str, JSONValue]
16
+
17
+ _CONTAINER_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
18
+
19
+
20
+ class ToolGenerator:
21
+ def generate(self, spec_path: str) -> dict[str, Callable[..., JSONDict]]:
22
+ integration_mod = import_module("claude_experimental.integration")
23
+ require_enabled_obj = integration_mod.__dict__.get("_require_enabled")
24
+ if not callable(require_enabled_obj):
25
+ raise RuntimeError("integration feature gate is unavailable")
26
+ require_enabled = cast(Callable[[], None], require_enabled_obj)
27
+ _ = require_enabled()
28
+
29
+ spec = self._load_spec(spec_path)
30
+ operations = self._iter_operations(spec)
31
+ return {name: self._build_callable(meta) for name, meta in operations.items()}
32
+
33
+ def generate_module(self, spec_path: str) -> str:
34
+ integration_mod = import_module("claude_experimental.integration")
35
+ require_enabled_obj = integration_mod.__dict__.get("_require_enabled")
36
+ if not callable(require_enabled_obj):
37
+ raise RuntimeError("integration feature gate is unavailable")
38
+ require_enabled = cast(Callable[[], None], require_enabled_obj)
39
+ _ = require_enabled()
40
+
41
+ spec = self._load_spec(spec_path)
42
+ operations = self._iter_operations(spec)
43
+
44
+ lines: list[str] = [
45
+ "from __future__ import annotations",
46
+ "",
47
+ "import json",
48
+ "from urllib import parse, request",
49
+ "from urllib.error import HTTPError",
50
+ "",
51
+ "",
52
+ "def _map_http_error(err: HTTPError) -> Exception:",
53
+ " if err.code == 404:",
54
+ " return FileNotFoundError(f'HTTP 404: {err.reason}')",
55
+ " if err.code == 400:",
56
+ " return ValueError(f'HTTP 400: {err.reason}')",
57
+ " if err.code >= 500:",
58
+ " return RuntimeError(f'HTTP {err.code}: {err.reason}')",
59
+ " return RuntimeError(f'HTTP {err.code}: {err.reason}')",
60
+ "",
61
+ "",
62
+ "def _call_endpoint(base_url: str, path: str, method: str, query_params: dict, body):",
63
+ " query = {k: v for k, v in query_params.items() if v is not None}",
64
+ " query_string = parse.urlencode(query, doseq=True)",
65
+ " url = f\"{base_url.rstrip('/')}\" + path",
66
+ " if query_string:",
67
+ " url = f\"{url}?{query_string}\"",
68
+ " payload = None",
69
+ " headers = {}",
70
+ " if body is not None:",
71
+ " payload = json.dumps(body).encode('utf-8')",
72
+ " headers['Content-Type'] = 'application/json'",
73
+ " req = request.Request(url, data=payload, method=method.upper(), headers=headers)",
74
+ " try:",
75
+ " with request.urlopen(req) as response:",
76
+ " response_bytes = response.read()",
77
+ " if not response_bytes:",
78
+ " return {}",
79
+ " return json.loads(response_bytes.decode('utf-8'))",
80
+ " except HTTPError as err:",
81
+ " raise _map_http_error(err) from err",
82
+ "",
83
+ ]
84
+
85
+ for endpoint_name, meta in operations.items():
86
+ path_params = meta.path_params
87
+ signature_items = ["base_url", *path_params, "body=None", "**query_params"]
88
+ signature = ", ".join(signature_items)
89
+ lines.append(f"def {endpoint_name}({signature}):")
90
+ lines.append(f" path = {meta.path_template!r}")
91
+ for param_name in path_params:
92
+ lines.append(
93
+ f" path = path.replace('{{{param_name}}}', parse.quote(str({param_name}), safe=''))"
94
+ )
95
+ lines.append(
96
+ f" return _call_endpoint(base_url=base_url, path=path, method={meta.method!r}, query_params=query_params, body=body)"
97
+ )
98
+ lines.append("")
99
+
100
+ return "\n".join(lines).rstrip() + "\n"
101
+
102
+ def _build_callable(self, meta: _OperationMeta) -> Callable[..., JSONDict]:
103
+ method = meta.method.upper()
104
+ path_template = meta.path_template
105
+ path_params = meta.path_params
106
+
107
+ def endpoint(
108
+ base_url: str,
109
+ *args: object,
110
+ body: JSONDict | None = None,
111
+ **query_params: object,
112
+ ) -> JSONDict:
113
+ if len(args) < len(path_params):
114
+ missing = path_params[len(args) :]
115
+ raise ValueError(f"Missing path parameters: {', '.join(missing)}")
116
+
117
+ path = path_template
118
+ for name, value in zip(path_params, args):
119
+ path = path.replace("{" + name + "}", parse.quote(str(value), safe=""))
120
+
121
+ query = {key: value for key, value in query_params.items() if value is not None}
122
+ query_string = parse.urlencode(query, doseq=True)
123
+ url = f"{base_url.rstrip('/')}" + path
124
+ if query_string:
125
+ url = f"{url}?{query_string}"
126
+
127
+ payload: bytes | None = None
128
+ headers: dict[str, str] = {}
129
+ if body is not None:
130
+ payload = json.dumps(body).encode("utf-8")
131
+ headers["Content-Type"] = "application/json"
132
+
133
+ req = request.Request(url=url, data=payload, method=method, headers=headers)
134
+ try:
135
+ with cast(_HTTPResponse, request.urlopen(req)) as response:
136
+ data = response.read()
137
+ if not data:
138
+ return {}
139
+ decoded: str = data.decode("utf-8")
140
+ loaded = cast(object, json.loads(decoded))
141
+ if isinstance(loaded, dict):
142
+ return cast(JSONDict, loaded)
143
+ return {"data": cast(JSONValue, loaded)}
144
+ except HTTPError as err:
145
+ raise self._map_http_error(err) from err
146
+
147
+ endpoint.__name__ = meta.endpoint_name
148
+ setattr(endpoint, "__signature__", self._build_signature(path_params))
149
+ return endpoint
150
+
151
+ def _build_signature(self, path_params: list[str]) -> inspect.Signature:
152
+ parameters = [inspect.Parameter("base_url", inspect.Parameter.POSITIONAL_OR_KEYWORD)]
153
+ for param_name in path_params:
154
+ parameters.append(inspect.Parameter(param_name, inspect.Parameter.POSITIONAL_OR_KEYWORD))
155
+ parameters.append(inspect.Parameter("body", inspect.Parameter.KEYWORD_ONLY, default=None))
156
+ parameters.append(inspect.Parameter("query_params", inspect.Parameter.VAR_KEYWORD))
157
+ return inspect.Signature(parameters, return_annotation=dict[str, JSONValue])
158
+
159
+ def _iter_operations(self, spec: JSONDict) -> dict[str, _OperationMeta]:
160
+ paths_value = spec.get("paths")
161
+ if not isinstance(paths_value, dict):
162
+ return {}
163
+
164
+ operations: dict[str, _OperationMeta] = {}
165
+ for path_key, path_item_value in paths_value.items():
166
+ if not isinstance(path_item_value, dict):
167
+ continue
168
+
169
+ path_item = cast(JSONDict, path_item_value)
170
+ path_level_params = self._extract_params(path_item.get("parameters"))
171
+ for method_key, op_value in path_item.items():
172
+ method = method_key.lower()
173
+ if method not in _CONTAINER_METHODS or not isinstance(op_value, dict):
174
+ continue
175
+
176
+ operation = cast(JSONDict, op_value)
177
+ op_params = self._extract_params(operation.get("parameters"))
178
+ merged_params = self._merge_params(path_level_params, op_params)
179
+ path_params = [param.name for param in merged_params if param.location == "path"]
180
+ endpoint_name = self._endpoint_name(method, path_key, operation)
181
+ operations[endpoint_name] = _OperationMeta(
182
+ endpoint_name=endpoint_name,
183
+ method=method,
184
+ path_template=path_key,
185
+ path_params=path_params,
186
+ )
187
+
188
+ return operations
189
+
190
+ def _merge_params(self, base: list[_ParamMeta], extra: list[_ParamMeta]) -> list[_ParamMeta]:
191
+ result: list[_ParamMeta] = []
192
+ seen: set[tuple[str, str]] = set()
193
+ for param in base + extra:
194
+ key = (param.name, param.location)
195
+ if key in seen:
196
+ continue
197
+ seen.add(key)
198
+ result.append(param)
199
+ return result
200
+
201
+ def _extract_params(self, raw_params: JSONValue) -> list[_ParamMeta]:
202
+ if not isinstance(raw_params, list):
203
+ return []
204
+
205
+ result: list[_ParamMeta] = []
206
+ for item in raw_params:
207
+ if not isinstance(item, dict):
208
+ continue
209
+ name = item.get("name")
210
+ location = item.get("in")
211
+ if isinstance(name, str) and isinstance(location, str):
212
+ result.append(_ParamMeta(name=name, location=location))
213
+ return result
214
+
215
+ def _endpoint_name(self, method: str, path_value: str, operation: JSONDict) -> str:
216
+ operation_id = operation.get("operationId")
217
+ if isinstance(operation_id, str) and operation_id.strip():
218
+ return self._sanitize_identifier(operation_id)
219
+
220
+ parts = [method]
221
+ for part in path_value.strip("/").split("/"):
222
+ if not part:
223
+ continue
224
+ if part.startswith("{") and part.endswith("}"):
225
+ parts.append(f"by_{part[1:-1]}")
226
+ else:
227
+ parts.append(part.replace("-", "_"))
228
+ return self._sanitize_identifier("_".join(parts))
229
+
230
+ def _sanitize_identifier(self, value: str) -> str:
231
+ name = re.sub(r"[^0-9a-zA-Z_]", "_", value)
232
+ name = re.sub(r"_+", "_", name).strip("_")
233
+ if not name:
234
+ return "endpoint"
235
+ if name[0].isdigit():
236
+ return f"endpoint_{name}"
237
+ return name
238
+
239
+ def _load_spec(self, spec_path: str) -> JSONDict:
240
+ raw_text = Path(spec_path).read_text(encoding="utf-8")
241
+ suffix = Path(spec_path).suffix.lower()
242
+ if suffix == ".json":
243
+ parsed_obj = cast(object, json.loads(raw_text))
244
+ else:
245
+ parsed_obj = self._parse_yaml(raw_text)
246
+ if not isinstance(parsed_obj, dict):
247
+ raise ValueError("OpenAPI spec root must be an object")
248
+ return cast(JSONDict, parsed_obj)
249
+
250
+ def _parse_yaml(self, raw_text: str) -> JSONDict:
251
+ lines = raw_text.splitlines()
252
+ root: JSONDict = {}
253
+ stack: list[tuple[int, _Container]] = [(-1, root)]
254
+ index = 0
255
+
256
+ while index < len(lines):
257
+ raw_line = lines[index]
258
+ index += 1
259
+ if not raw_line.strip() or raw_line.lstrip().startswith("#"):
260
+ continue
261
+
262
+ indent = len(raw_line) - len(raw_line.lstrip(" "))
263
+ line = raw_line.strip()
264
+ while len(stack) > 1 and indent <= stack[-1][0]:
265
+ _ = stack.pop()
266
+ parent = stack[-1][1]
267
+
268
+ if line.startswith("- "):
269
+ if not isinstance(parent, list):
270
+ raise ValueError(f"Invalid YAML list placement: {line}")
271
+ item_text = line[2:].strip()
272
+ if not item_text:
273
+ new_item: JSONDict = {}
274
+ parent.append(new_item)
275
+ stack.append((indent, new_item))
276
+ continue
277
+ if ":" in item_text and not item_text.startswith("{"):
278
+ key, value = self._split_key_value(item_text)
279
+ entry: JSONDict = {key: self._parse_scalar(value)}
280
+ parent.append(entry)
281
+ stack.append((indent, entry))
282
+ continue
283
+ parent.append(self._parse_scalar(item_text))
284
+ continue
285
+
286
+ key, value = self._split_key_value(line)
287
+ target: JSONDict
288
+ if isinstance(parent, list):
289
+ if not parent or not isinstance(parent[-1], dict):
290
+ parent.append({})
291
+ last_item = parent[-1]
292
+ if not isinstance(last_item, dict):
293
+ raise ValueError("Expected mapping item inside YAML list")
294
+ target = cast(JSONDict, last_item)
295
+ else:
296
+ target = parent
297
+
298
+ if value == "":
299
+ container = self._detect_container(lines, index, indent)
300
+ target[key] = container
301
+ stack.append((indent, container))
302
+ else:
303
+ target[key] = self._parse_scalar(value)
304
+
305
+ return root
306
+
307
+ def _split_key_value(self, line: str) -> tuple[str, str]:
308
+ if ":" not in line:
309
+ raise ValueError(f"Invalid YAML line: {line}")
310
+ key, value = line.split(":", 1)
311
+ return key.strip(), value.strip()
312
+
313
+ def _detect_container(self, lines: list[str], start_idx: int, current_indent: int) -> _Container:
314
+ idx = start_idx
315
+ while idx < len(lines):
316
+ candidate = lines[idx]
317
+ idx += 1
318
+ if not candidate.strip() or candidate.lstrip().startswith("#"):
319
+ continue
320
+ indent = len(candidate) - len(candidate.lstrip(" "))
321
+ if indent <= current_indent:
322
+ return {}
323
+ if candidate.strip().startswith("- "):
324
+ return []
325
+ return {}
326
+ return {}
327
+
328
+ def _parse_scalar(self, value: str) -> JSONValue:
329
+ if value in {"", "null", "~"}:
330
+ return None
331
+ if value in {"true", "True"}:
332
+ return True
333
+ if value in {"false", "False"}:
334
+ return False
335
+ if value.startswith("{") and value.endswith("}"):
336
+ return self._parse_inline_map(value[1:-1].strip())
337
+ if value.startswith("[") and value.endswith("]"):
338
+ return self._parse_inline_list(value[1:-1].strip())
339
+ if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
340
+ return value[1:-1]
341
+ if re.fullmatch(r"-?\d+", value):
342
+ return int(value)
343
+ if re.fullmatch(r"-?\d+\.\d+", value):
344
+ return float(value)
345
+ return value
346
+
347
+ def _parse_inline_map(self, content: str) -> JSONDict:
348
+ if not content:
349
+ return {}
350
+ result: JSONDict = {}
351
+ for item in self._split_inline_items(content):
352
+ key, value = self._split_key_value(item)
353
+ result[key] = self._parse_scalar(value)
354
+ return result
355
+
356
+ def _parse_inline_list(self, content: str) -> list[JSONValue]:
357
+ if not content:
358
+ return []
359
+ return [self._parse_scalar(item) for item in self._split_inline_items(content)]
360
+
361
+ def _split_inline_items(self, text: str) -> list[str]:
362
+ items: list[str] = []
363
+ current: list[str] = []
364
+ depth = 0
365
+ quote: str | None = None
366
+
367
+ for char in text:
368
+ if quote is not None:
369
+ current.append(char)
370
+ if char == quote:
371
+ quote = None
372
+ continue
373
+ if char in {'"', "'"}:
374
+ quote = char
375
+ current.append(char)
376
+ continue
377
+ if char in "[{":
378
+ depth += 1
379
+ current.append(char)
380
+ continue
381
+ if char in "]}":
382
+ depth -= 1
383
+ current.append(char)
384
+ continue
385
+ if char == "," and depth == 0:
386
+ item = "".join(current).strip()
387
+ if item:
388
+ items.append(item)
389
+ current = []
390
+ continue
391
+ current.append(char)
392
+
393
+ tail = "".join(current).strip()
394
+ if tail:
395
+ items.append(tail)
396
+ return items
397
+
398
+ def _map_http_error(self, err: HTTPError) -> Exception:
399
+ if err.code == 404:
400
+ return FileNotFoundError(f"HTTP 404: {err.reason}")
401
+ if err.code == 400:
402
+ return ValueError(f"HTTP 400: {err.reason}")
403
+ if err.code >= 500:
404
+ return RuntimeError(f"HTTP {err.code}: {err.reason}")
405
+ return RuntimeError(f"HTTP {err.code}: {err.reason}")
406
+
407
+
408
+ @dataclass
409
+ class _ParamMeta:
410
+ name: str
411
+ location: str
412
+
413
+
414
+ @dataclass
415
+ class _OperationMeta:
416
+ endpoint_name: str
417
+ method: str
418
+ path_template: str
419
+ path_params: list[str]
420
+
421
+
422
+ class _HTTPResponse(Protocol):
423
+ def __enter__(self) -> "_HTTPResponse": ...
424
+ def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> bool | None: ...
425
+ def read(self) -> bytes: ...
426
+
427
+
428
+ _Container: TypeAlias = JSONDict | list[JSONValue]
@@ -0,0 +1,131 @@
1
+ """claude_experimental.integration.streaming — Server-Sent Events (SSE) streaming for agent output."""
2
+ from __future__ import annotations
3
+
4
+ import threading
5
+ import uuid
6
+ from collections import deque
7
+ from typing import Generator, Optional
8
+
9
+
10
+ class SSEStream:
11
+ """
12
+ Server-Sent Events (SSE) streaming buffer for agent output.
13
+
14
+ Provides in-memory buffering with backpressure handling and thread-safe access.
15
+ Events are formatted as SSE-compliant strings and can be read with optional
16
+ last_event_id filtering for resumable streams.
17
+
18
+ Example:
19
+ >>> stream = SSEStream(max_buffer=100)
20
+ >>> stream.emit('content', 'Hello, world!')
21
+ >>> for sse_line in stream.read():
22
+ ... print(sse_line)
23
+ id: <uuid>
24
+ event: content
25
+ data: Hello, world!
26
+ <BLANKLINE>
27
+ """
28
+
29
+ def __init__(self, max_buffer: int = 100) -> None:
30
+ """
31
+ Initialize SSE stream with bounded buffer.
32
+
33
+ Args:
34
+ max_buffer: Maximum number of events to buffer. Oldest events are
35
+ dropped when buffer overflows (FIFO backpressure).
36
+ """
37
+ from claude_experimental.integration import _require_enabled
38
+
39
+ _require_enabled()
40
+
41
+ self._max_buffer = max_buffer
42
+ self._buffer: deque[dict[str, str]] = deque(maxlen=max_buffer)
43
+ self._lock = threading.Lock()
44
+ self._closed = False
45
+
46
+ def emit(self, event_type: str, data: str, event_id: Optional[str] = None) -> None:
47
+ """
48
+ Emit an event to the stream buffer.
49
+
50
+ Args:
51
+ event_type: Event type (e.g., 'content', 'progress', 'error', 'complete').
52
+ data: Event payload as string.
53
+ event_id: Optional event ID. If not provided, a UUID is generated.
54
+
55
+ Raises:
56
+ RuntimeError: If stream is closed or feature flag is disabled.
57
+ """
58
+ from claude_experimental.integration import _require_enabled
59
+
60
+ _require_enabled()
61
+
62
+ if self._closed:
63
+ raise RuntimeError("Cannot emit to closed stream")
64
+
65
+ if event_id is None:
66
+ event_id = str(uuid.uuid4())
67
+
68
+ event = {"id": event_id, "event": event_type, "data": data}
69
+
70
+ with self._lock:
71
+ self._buffer.append(event)
72
+
73
+ def read(self, last_event_id: Optional[str] = None) -> Generator[str, None, None]:
74
+ """
75
+ Read events from the stream as SSE-formatted strings.
76
+
77
+ Yields events in FIFO order, optionally skipping events before a given
78
+ last_event_id for resumable streams.
79
+
80
+ Args:
81
+ last_event_id: Optional event ID to resume from. Events with IDs
82
+ before this one are skipped.
83
+
84
+ Yields:
85
+ SSE-formatted event strings (including trailing blank line).
86
+
87
+ Raises:
88
+ RuntimeError: If feature flag is disabled.
89
+
90
+ Example:
91
+ >>> stream = SSEStream()
92
+ >>> stream.emit('content', 'test')
93
+ >>> for line in stream.read():
94
+ ... print(repr(line))
95
+ 'id: ...'
96
+ 'event: content'
97
+ 'data: test'
98
+ ''
99
+ """
100
+ from claude_experimental.integration import _require_enabled
101
+
102
+ _require_enabled()
103
+
104
+ with self._lock:
105
+ events = list(self._buffer)
106
+
107
+ # Filter events if resuming from last_event_id
108
+ if last_event_id is not None:
109
+ found_index = -1
110
+ for i, event in enumerate(events):
111
+ if event["id"] == last_event_id:
112
+ found_index = i
113
+ break
114
+ if found_index >= 0:
115
+ events = events[found_index + 1 :]
116
+
117
+ # Yield SSE-formatted events
118
+ for event in events:
119
+ yield f"id: {event['id']}"
120
+ yield f"event: {event['event']}"
121
+ yield f"data: {event['data']}"
122
+ yield "" # Blank line terminates event
123
+
124
+ def close(self) -> None:
125
+ """
126
+ Signal that the stream is closed.
127
+
128
+ Subsequent calls to emit() will raise RuntimeError.
129
+ """
130
+ with self._lock:
131
+ self._closed = True