@code-yeongyu/senpi-codemode 2026.7.25-2

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 (63) hide show
  1. package/CHANGELOG.md +250 -0
  2. package/LICENSE +22 -0
  3. package/README.md +161 -0
  4. package/package.json +58 -0
  5. package/src/bridge/http-server.ts +236 -0
  6. package/src/bridge/protocol.ts +198 -0
  7. package/src/bridge/reserved.ts +9 -0
  8. package/src/bridges/agent-bridge.ts +197 -0
  9. package/src/bridges/output-bridge.ts +96 -0
  10. package/src/bridges/schema-injection.ts +3 -0
  11. package/src/codemode/runtime.ts +258 -0
  12. package/src/codemode/tools.ts +106 -0
  13. package/src/completion/handler.ts +192 -0
  14. package/src/completion/tool-bridge.ts +55 -0
  15. package/src/config/settings.ts +215 -0
  16. package/src/extension/runtime-factory.ts +114 -0
  17. package/src/extension/session-manager-proxy.ts +116 -0
  18. package/src/extension/session-manager.ts +215 -0
  19. package/src/host-sdk.ts +1 -0
  20. package/src/index.ts +181 -0
  21. package/src/interpreters/detect.ts +161 -0
  22. package/src/kernels/jl/kernel.ts +37 -0
  23. package/src/kernels/jl/prelude.jl +283 -0
  24. package/src/kernels/jl/runner.jl +327 -0
  25. package/src/kernels/js/context-manager.ts +296 -0
  26. package/src/kernels/js/inline-worker-entry.js +23 -0
  27. package/src/kernels/js/inline-worker.ts +15 -0
  28. package/src/kernels/js/kernel-contract.ts +38 -0
  29. package/src/kernels/js/local-module-loader.ts +108 -0
  30. package/src/kernels/js/prelude.ts +15 -0
  31. package/src/kernels/js/rewrite-imports.ts +164 -0
  32. package/src/kernels/js/run-queue.ts +82 -0
  33. package/src/kernels/js/worker-core.d.ts +18 -0
  34. package/src/kernels/js/worker-core.js +94 -0
  35. package/src/kernels/js/worker-entry.js +23 -0
  36. package/src/kernels/js/worker-host.ts +117 -0
  37. package/src/kernels/js/worker-indirect-eval.js +88 -0
  38. package/src/kernels/js/worker-runtime.js +401 -0
  39. package/src/kernels/py/kernel-contract.ts +32 -0
  40. package/src/kernels/py/kernel.ts +290 -0
  41. package/src/kernels/py/prelude.py +954 -0
  42. package/src/kernels/py/process.ts +119 -0
  43. package/src/kernels/py/transport.ts +237 -0
  44. package/src/kernels/rb/kernel.ts +26 -0
  45. package/src/kernels/rb/prelude.rb +270 -0
  46. package/src/kernels/rb/runner.rb +204 -0
  47. package/src/kernels/shared/subprocess-contract.ts +22 -0
  48. package/src/kernels/shared/subprocess-kernel.ts +266 -0
  49. package/src/kernels/shared/subprocess-process.ts +174 -0
  50. package/src/kernels/shared/subprocess-queue.ts +101 -0
  51. package/src/kernels/shared/subprocess-run.ts +98 -0
  52. package/src/output/output-meta.ts +89 -0
  53. package/src/output/streaming-output.ts +296 -0
  54. package/src/prompt/eval-prompt.ts +319 -0
  55. package/src/timeouts/bridge-timeout.ts +16 -0
  56. package/src/timeouts/idle-timeout.ts +84 -0
  57. package/src/tool/cell-handler.ts +279 -0
  58. package/src/tool/eval-tool.ts +285 -0
  59. package/src/tool/image.ts +274 -0
  60. package/src/tool/json-tree.ts +247 -0
  61. package/src/tool/render.ts +876 -0
  62. package/src/tool/status-events.ts +12 -0
  63. package/src/tool/types.ts +114 -0
@@ -0,0 +1,954 @@
1
+ from __future__ import annotations
2
+
3
+ # noqa: SIZE_OK — this dependency-free subprocess prelude must ship as one file.
4
+ import ast
5
+ import asyncio # noqa: ANYIO_OK — stdlib-only embedded kernel runner.
6
+ import base64
7
+ import codecs
8
+ import contextlib
9
+ import inspect
10
+ import io
11
+ import json
12
+ import locale
13
+ import os
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ import traceback
19
+ import urllib.error
20
+ import urllib.request
21
+ import uuid
22
+ from collections.abc import Iterable
23
+ from concurrent.futures import ThreadPoolExecutor, as_completed
24
+ from pathlib import Path
25
+ from threading import Lock
26
+ from typing import Any, Callable
27
+ from urllib.parse import unquote
28
+
29
+ SESSION_ID = ""
30
+ CONNECTION: dict[str, Any] = {}
31
+ USER_NS: dict[str, Any] = {"__name__": "__main__", "__doc__": None, "__builtins__": __builtins__}
32
+ LOOP = asyncio.new_event_loop()
33
+ asyncio.set_event_loop(LOOP)
34
+ EMIT_LOCK = Lock()
35
+
36
+ # Mirrors src/bridge/reserved.ts; this standalone subprocess asset cannot import TypeScript.
37
+ RESERVED_AGENT_TOOL = "__agent__"
38
+ RESERVED_OUTPUT_TOOL = "__output__"
39
+ TIMEOUT_PAUSE_OP = "timeout-pause"
40
+ TIMEOUT_RESUME_OP = "timeout-resume"
41
+
42
+
43
+ class PreludeRuntimeError(RuntimeError):
44
+ """Host bridge or magic execution failed."""
45
+
46
+
47
+ class PreludeValueError(ValueError):
48
+ """A helper received an invalid value."""
49
+
50
+
51
+ class PreludeTypeError(TypeError):
52
+ """A helper received an invalid value type."""
53
+
54
+ _INTERNAL_URL_RE = re.compile(r"^([a-z][a-z0-9+.-]*)://(.*)$", re.IGNORECASE)
55
+ _ASSIGN_LINE_RE = re.compile(
56
+ r"^(?P<indent>[ \t]*)(?P<lhs>[A-Za-z_][A-Za-z_0-9.\[\], ]*?)\s*=\s*(?P<rhs>.+)$"
57
+ )
58
+ _SHELL_READ_CHUNK_BYTES = 8192
59
+ _SHELL_CAPTURE_MAX_BYTES = 1024 * 1024
60
+ _SHELL_CAPTURE_MAX_LINES = 3000
61
+ _SHELL_TRUNCATION_NOTICE = (
62
+ f"[output truncated: shell helper exceeded {_SHELL_CAPTURE_MAX_BYTES} bytes "
63
+ f"or {_SHELL_CAPTURE_MAX_LINES} lines; remaining output discarded]\n"
64
+ )
65
+ os.environ.setdefault("MPLBACKEND", "Agg")
66
+
67
+
68
+ def emit(frame: dict[str, Any]) -> None:
69
+ encoded = json.dumps(frame, ensure_ascii=False, default=repr) + "\n"
70
+ with EMIT_LOCK:
71
+ sys.__stdout__.write(encoded)
72
+ sys.__stdout__.flush()
73
+
74
+
75
+ def bridge_error(exc: BaseException) -> dict[str, str]:
76
+ return {
77
+ "name": type(exc).__name__,
78
+ "message": str(exc),
79
+ "stack": "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)),
80
+ }
81
+
82
+
83
+ def text(stream: str, data: str) -> None:
84
+ if data:
85
+ emit({"type": "text", "stream": stream, "data": data})
86
+
87
+
88
+ def b64_text(value: str) -> str:
89
+ return base64.b64encode(value.encode("utf-8")).decode("ascii")
90
+
91
+
92
+ def _emit_display(mime_type: str, data: Any) -> None:
93
+ if isinstance(data, (bytes, bytearray)):
94
+ encoded = base64.b64encode(bytes(data)).decode("ascii")
95
+ elif mime_type.startswith("image/"):
96
+ if isinstance(data, str):
97
+ encoded = data
98
+ else:
99
+ encoded = base64.b64encode(repr(data).encode("utf-8")).decode("ascii")
100
+ elif mime_type == "application/json":
101
+ encoded = b64_text(json.dumps(data, ensure_ascii=False, default=repr))
102
+ else:
103
+ encoded = b64_text(str(data))
104
+ emit({"type": "display", "mimeType": mime_type, "dataBase64": encoded})
105
+
106
+
107
+ def _display_bundle(bundle: dict[str, Any]) -> bool:
108
+ for mime_type in (
109
+ "image/png",
110
+ "image/jpeg",
111
+ "application/json",
112
+ "text/markdown",
113
+ "text/html",
114
+ "image/svg+xml",
115
+ "text/latex",
116
+ "text/plain",
117
+ ):
118
+ if mime_type in bundle:
119
+ _emit_display(mime_type, bundle[mime_type])
120
+ return True
121
+ return False
122
+
123
+
124
+ def _is_matplotlib_figure(value: Any) -> bool:
125
+ figure_module = sys.modules.get("matplotlib.figure")
126
+ figure_class = getattr(figure_module, "Figure", None)
127
+ if isinstance(figure_class, type) and isinstance(value, figure_class):
128
+ return True
129
+ value_type = type(value)
130
+ return value_type.__module__ == "matplotlib.figure" and value_type.__name__ == "Figure"
131
+
132
+
133
+ def _matplotlib_png(value: Any) -> bytes | None:
134
+ if not _is_matplotlib_figure(value):
135
+ return None
136
+ savefig = getattr(value, "savefig", None)
137
+ if not callable(savefig):
138
+ return None
139
+ try:
140
+ buffer = io.BytesIO()
141
+ savefig(buffer, format="png", bbox_inches="tight")
142
+ return buffer.getvalue()
143
+ except Exception: # noqa: BROAD_EXCEPT_OK — user-defined rendering hooks are isolated fallbacks.
144
+ return None
145
+
146
+
147
+ def _rich_bundle(value: Any) -> dict[str, Any]:
148
+ bundle: dict[str, Any] = {}
149
+ mime_bundle = getattr(value, "_repr_mimebundle_", None)
150
+ if callable(mime_bundle):
151
+ try:
152
+ data = mime_bundle()
153
+ if isinstance(data, tuple):
154
+ data = data[0]
155
+ if isinstance(data, dict):
156
+ bundle.update({str(key): item for key, item in data.items()})
157
+ except Exception: # noqa: BROAD_EXCEPT_OK — a broken repr must fall through to the next representation.
158
+ bundle.clear()
159
+
160
+ for attribute, mime_type in (
161
+ ("_repr_markdown_", "text/markdown"),
162
+ ("_repr_png_", "image/png"),
163
+ ("_repr_jpeg_", "image/jpeg"),
164
+ ("_repr_html_", "text/html"),
165
+ ("_repr_json_", "application/json"),
166
+ ("_repr_svg_", "image/svg+xml"),
167
+ ("_repr_latex_", "text/latex"),
168
+ ):
169
+ if mime_type in bundle:
170
+ continue
171
+ representation = getattr(value, attribute, None)
172
+ if not callable(representation):
173
+ continue
174
+ try:
175
+ data = representation()
176
+ except Exception: # noqa: BROAD_EXCEPT_OK — a broken repr must fall through to the next representation.
177
+ continue
178
+ if data is not None:
179
+ bundle[mime_type] = data
180
+
181
+ if "image/png" not in bundle:
182
+ figure_png = _matplotlib_png(value)
183
+ if figure_png is not None:
184
+ bundle["image/png"] = figure_png
185
+ return bundle
186
+
187
+
188
+ def display(value: Any) -> None:
189
+ if isinstance(value, (dict, list, tuple)):
190
+ _emit_display("application/json", value)
191
+ return
192
+ if isinstance(value, (bytes, bytearray)):
193
+ _emit_display("application/octet-stream", bytes(value))
194
+ return
195
+ bundle = _rich_bundle(value)
196
+ if bundle and _display_bundle(bundle):
197
+ return
198
+ _emit_display("text/plain", str(value))
199
+
200
+
201
+ def _status_events_enabled() -> bool:
202
+ return CONNECTION.get("statusEvents", True) is not False
203
+
204
+
205
+ def emit_status(op: str, *, force: bool = False, **data: Any) -> None:
206
+ if force or _status_events_enabled():
207
+ emit({"type": "status", "event": {"op": op, **data}})
208
+
209
+
210
+ def log(message: Any) -> None:
211
+ emit({"type": "log", "message": str(message)})
212
+
213
+
214
+ def phase(title: Any) -> None:
215
+ emit({"type": "phase", "title": str(title)})
216
+
217
+
218
+ def env(key: str | None = None, value: str | None = None) -> Any:
219
+ if key is None:
220
+ items = dict(sorted(os.environ.items()))
221
+ emit_status("env", count=len(items), keys=list(items.keys())[:20])
222
+ return items
223
+ if value is not None:
224
+ os.environ[key] = value
225
+ emit_status("env", key=key, value=value, action="set")
226
+ return value
227
+ resolved = os.environ.get(key)
228
+ emit_status("env", key=key, value=resolved, action="get")
229
+ return resolved
230
+
231
+
232
+ def _resolve_helper_path(path: str | Path) -> Path:
233
+ if not isinstance(path, str):
234
+ return Path(path)
235
+ match = _INTERNAL_URL_RE.match(path)
236
+ if not match:
237
+ return Path(path)
238
+ scheme = match.group(1).lower()
239
+ roots = CONNECTION.get("localRoots")
240
+ root = roots.get(scheme) if isinstance(roots, dict) else None
241
+ if not isinstance(root, str) or not root:
242
+ raise PreludeValueError(f"Protocol paths are not supported by this helper: {path}")
243
+ relative = unquote(match.group(2).replace("\\", "/"))
244
+ root_path = os.path.abspath(root)
245
+ if relative == "":
246
+ return Path(root_path)
247
+ relative_path = Path(relative)
248
+ if relative_path.is_absolute() or ".." in relative_path.parts:
249
+ raise PreludeValueError(f"Unsafe {scheme}:// path (absolute or traversal): {path}")
250
+ resolved = os.path.abspath(os.path.join(root_path, relative))
251
+ if resolved != root_path and not resolved.startswith(root_path + os.sep):
252
+ raise PreludeValueError(f"{scheme}:// path escapes its root: {path}")
253
+ return Path(resolved)
254
+
255
+
256
+ def read(path: str | Path, offset: int = 1, limit: int | None = None) -> str:
257
+ target = _resolve_helper_path(path)
258
+ data = target.read_text(encoding="utf-8")
259
+ if offset > 1 or limit is not None:
260
+ lines = data.splitlines(keepends=True)
261
+ start = max(0, offset - 1)
262
+ end = start + limit if limit is not None else len(lines)
263
+ data = "".join(lines[start:end])
264
+ emit_status("read", path=str(target), chars=len(data), preview=data[:500])
265
+ return data
266
+
267
+
268
+ def write(path: str | Path, content: str) -> Path:
269
+ target = _resolve_helper_path(path)
270
+ target.parent.mkdir(parents=True, exist_ok=True)
271
+ target.write_text(content, encoding="utf-8")
272
+ emit_status("write", path=str(target), chars=len(content))
273
+ return target
274
+
275
+
276
+ def bridge_post(path: str, payload: dict[str, Any]) -> Any:
277
+ port = CONNECTION.get("port")
278
+ token = CONNECTION.get("token")
279
+ if not isinstance(port, int) or not isinstance(token, str):
280
+ raise PreludeRuntimeError("Python tool bridge is not initialized")
281
+ request_data = json.dumps(payload, ensure_ascii=False, default=repr).encode("utf-8")
282
+ request = urllib.request.Request(
283
+ f"http://127.0.0.1:{port}{path}",
284
+ data=request_data,
285
+ headers={"authorization": f"Bearer {token}", "content-type": "application/json"},
286
+ method="POST",
287
+ )
288
+ emit_status(TIMEOUT_PAUSE_OP, force=True)
289
+ try:
290
+ try:
291
+ with urllib.request.urlopen(request, timeout=60) as response:
292
+ response_data = response.read()
293
+ except urllib.error.HTTPError as exc:
294
+ response_data = exc.read()
295
+ finally:
296
+ emit_status(TIMEOUT_RESUME_OP, force=True)
297
+
298
+ try:
299
+ body = json.loads(response_data.decode("utf-8"))
300
+ except json.JSONDecodeError as exc:
301
+ raise PreludeRuntimeError(f"Bridge returned invalid JSON: {response_data[:200]!r}") from exc
302
+ if isinstance(body, dict) and body.get("ok") is True:
303
+ return body.get("value")
304
+ error = body.get("error") if isinstance(body, dict) else body
305
+ if isinstance(error, dict):
306
+ raise PreludeRuntimeError(str(error.get("message", error)))
307
+ raise PreludeRuntimeError(str(error))
308
+
309
+
310
+ class ToolCallable:
311
+ __slots__ = ("_name",)
312
+
313
+ def __init__(self, name: str) -> None:
314
+ self._name = name
315
+
316
+ def __repr__(self) -> str:
317
+ return f"<tool.{self._name}>"
318
+
319
+ def __call__(self, args: Any = None, /, **kwargs: Any) -> Any:
320
+ if args is None:
321
+ merged: dict[str, Any] = {}
322
+ elif isinstance(args, dict):
323
+ merged = dict(args)
324
+ else:
325
+ raise PreludeTypeError(
326
+ f"tool.{self._name}(...) expects a dict of arguments (got {type(args).__name__})"
327
+ )
328
+ merged.update(kwargs)
329
+ return bridge_post(
330
+ "/call",
331
+ {"callId": f"py-{uuid.uuid4()}", "toolName": self._name, "args": merged},
332
+ )
333
+
334
+
335
+ class ToolProxy:
336
+ __slots__ = ()
337
+
338
+ def __getattr__(self, name: str) -> ToolCallable:
339
+ if name.startswith("_"):
340
+ raise AttributeError(name)
341
+ return ToolCallable(name)
342
+
343
+ def __getitem__(self, name: str) -> ToolCallable:
344
+ return ToolCallable(name)
345
+
346
+ def __repr__(self) -> str:
347
+ return "<tool proxy>"
348
+
349
+
350
+ tool = ToolProxy()
351
+
352
+
353
+ def completion(
354
+ prompt: str,
355
+ model: str = "default",
356
+ system: str | None = None,
357
+ schema: dict[str, Any] | None = None,
358
+ **kwargs: Any,
359
+ ) -> Any:
360
+ options: dict[str, Any] = {}
361
+ if model != "default":
362
+ options["model"] = model
363
+ options.update(kwargs)
364
+ if system is not None:
365
+ options["system"] = system
366
+ if schema is not None:
367
+ options["schema"] = schema
368
+ response = bridge_post("/completion", {"prompt": prompt, "opts": options})
369
+ if not isinstance(response, dict):
370
+ return response
371
+ if "value" in response:
372
+ return response["value"]
373
+ return response.get("text", response)
374
+
375
+
376
+ def output(
377
+ *ids: str,
378
+ format: str = "raw",
379
+ offset: int | None = None,
380
+ limit: int | None = None,
381
+ ) -> Any:
382
+ if not ids:
383
+ raise PreludeValueError("At least one output ID is required")
384
+ if format not in ("raw", "tail"):
385
+ raise PreludeValueError("output() format must be 'raw' or 'tail'")
386
+ args: dict[str, Any] = {"ids": list(ids), "format": format}
387
+ if offset is not None:
388
+ args["offset"] = offset
389
+ if limit is not None:
390
+ args["limit"] = limit
391
+ return bridge_post(
392
+ "/call",
393
+ {"callId": f"py-{uuid.uuid4()}", "toolName": RESERVED_OUTPUT_TOOL, "args": args},
394
+ )
395
+
396
+
397
+ def agent(
398
+ prompt: str,
399
+ *,
400
+ agent: str | None = "task",
401
+ model: str | None = None,
402
+ label: str | None = None,
403
+ schema: dict[str, Any] | None = None,
404
+ isolated: bool | None = None,
405
+ apply: bool | None = None,
406
+ merge: bool | None = None,
407
+ handle: bool = False,
408
+ ) -> Any:
409
+ args: dict[str, Any] = {"prompt": prompt}
410
+ if agent is not None:
411
+ args["agent"] = agent
412
+ if model is not None:
413
+ args["model"] = model
414
+ if label is not None:
415
+ args["label"] = label
416
+ if schema is not None:
417
+ args["schema"] = schema
418
+ if isolated is not None:
419
+ args["isolated"] = bool(isolated)
420
+ if apply is not None:
421
+ args["apply"] = bool(apply)
422
+ if merge is not None:
423
+ args["merge"] = bool(merge)
424
+ if handle:
425
+ args["handle"] = True
426
+
427
+ response = bridge_post(
428
+ "/call",
429
+ {"callId": f"py-{uuid.uuid4()}", "toolName": RESERVED_AGENT_TOOL, "args": args},
430
+ )
431
+ response_record = response if isinstance(response, dict) else {}
432
+ text_value = response_record.get("text", response)
433
+ parsed = response_record.get("data")
434
+ if schema is not None and "data" not in response_record:
435
+ parsed = json.loads(str(text_value))
436
+ elif schema is None:
437
+ parsed = text_value
438
+ if not handle:
439
+ return parsed
440
+
441
+ agent_id = response_record.get("id")
442
+ handle_value = response_record.get("handle")
443
+ if handle_value is None and agent_id is not None:
444
+ handle_value = f"agent://{agent_id}"
445
+ node: dict[str, Any] = {
446
+ "text": text_value,
447
+ "output": text_value,
448
+ "handle": handle_value,
449
+ "id": agent_id,
450
+ "agent": response_record.get("agent", agent),
451
+ }
452
+ if schema is not None:
453
+ node["data"] = parsed
454
+ for key in (
455
+ "isolated",
456
+ "patch_path",
457
+ "branch_name",
458
+ "nested_patches",
459
+ "changes_applied",
460
+ "isolation_summary",
461
+ ):
462
+ if key in response_record:
463
+ node[key] = response_record[key]
464
+ return node
465
+
466
+
467
+ def _pool_map(items: Iterable[Any], function: Callable[[Any], Any]) -> list[Any]:
468
+ values = list(items)
469
+ if not values:
470
+ return []
471
+ configured_width = CONNECTION.get("parallelPoolWidth", 4)
472
+ width = (
473
+ int(configured_width)
474
+ if isinstance(configured_width, (int, float)) and not isinstance(configured_width, bool)
475
+ else 4
476
+ )
477
+ workers = min(max(1, width), len(values))
478
+ results: list[Any] = [None] * len(values)
479
+ errors: dict[int, BaseException] = {}
480
+ with ThreadPoolExecutor(max_workers=workers) as pool:
481
+ futures = {pool.submit(function, value): index for index, value in enumerate(values)}
482
+ for future in as_completed(futures):
483
+ index = futures[future]
484
+ try:
485
+ results[index] = future.result()
486
+ except BaseException as exc: # noqa: BROAD_EXCEPT_OK — preserve user thunk failures for deterministic re-raise.
487
+ errors[index] = exc
488
+ if errors:
489
+ raise errors[min(errors)]
490
+ return results
491
+
492
+
493
+ def parallel(callables: Iterable[Callable[[], Any]]) -> list[Any]:
494
+ thunks = list(callables)
495
+ for thunk in thunks:
496
+ if not callable(thunk):
497
+ raise PreludeTypeError("parallel() expects an iterable of zero-arg callables")
498
+ return _pool_map(thunks, lambda thunk: thunk())
499
+
500
+
501
+ def pipeline(items: Iterable[Any], *stages: Callable[[Any], Any]) -> list[Any]:
502
+ values = list(items)
503
+ for stage in stages:
504
+ if not callable(stage):
505
+ raise PreludeTypeError("pipeline() stages must be callables")
506
+ values = _pool_map(values, stage)
507
+ return values
508
+
509
+
510
+ def _fold_continuations(lines: list[str], start: int) -> tuple[str, int]:
511
+ parts: list[str] = []
512
+ index = start
513
+ while index < len(lines):
514
+ line = lines[index]
515
+ if line.endswith("\\"):
516
+ parts.append(line[:-1])
517
+ index += 1
518
+ continue
519
+ parts.append(line)
520
+ index += 1
521
+ break
522
+ return "".join(parts), index - start
523
+
524
+
525
+ def _quote_arg(text_value: str) -> str:
526
+ return json.dumps(text_value, ensure_ascii=False)
527
+
528
+
529
+ def _split_magic_head(text_value: str) -> tuple[str, str]:
530
+ stripped = text_value.lstrip()
531
+ if not stripped:
532
+ return "", ""
533
+ match = re.match(r"([A-Za-z_][A-Za-z_0-9]*)(?:\s+(.*))?$", stripped)
534
+ if not match:
535
+ return "", stripped
536
+ return match.group(1), (match.group(2) or "").rstrip()
537
+
538
+
539
+ def _is_escaped(text_value: str, index: int) -> bool:
540
+ backslashes = 0
541
+ cursor = index - 1
542
+ while cursor >= 0 and text_value[cursor] == "\\":
543
+ backslashes += 1
544
+ cursor -= 1
545
+ return backslashes % 2 == 1
546
+
547
+
548
+ def _advance_triple_quote_state(line: str, active_quote: str | None) -> str | None:
549
+ index = 0
550
+ quote = active_quote
551
+ while index < len(line):
552
+ if quote is not None:
553
+ closing = line.find(quote, index)
554
+ if closing < 0:
555
+ return quote
556
+ if _is_escaped(line, closing):
557
+ index = closing + 1
558
+ continue
559
+ quote = None
560
+ index = closing + 3
561
+ continue
562
+
563
+ character = line[index]
564
+ if character == "#":
565
+ return None
566
+ if character not in ("'", '"'):
567
+ index += 1
568
+ continue
569
+ triple = character * 3
570
+ if line.startswith(triple, index):
571
+ quote = triple
572
+ index += 3
573
+ continue
574
+ index += 1
575
+ while index < len(line):
576
+ if line[index] == character and not _is_escaped(line, index):
577
+ index += 1
578
+ break
579
+ index += 1
580
+ return quote
581
+
582
+
583
+ def transform_cell(source: str) -> str:
584
+ if "%" not in source and "!" not in source:
585
+ return source
586
+
587
+ lines = source.splitlines()
588
+ transformed: list[str] = []
589
+ index = 0
590
+ triple_quote: str | None = None
591
+ while index < len(lines):
592
+ line = lines[index]
593
+ protected = triple_quote is not None
594
+ stripped = line.lstrip()
595
+ indent = line[: len(line) - len(stripped)]
596
+
597
+ if not protected and stripped.startswith("%%"):
598
+ name, args = _split_magic_head(stripped[2:])
599
+ body = "\n".join(lines[index + 1 :])
600
+ transformed.append(
601
+ f"{indent}__senpi_magic_cell({_quote_arg(name)}, {_quote_arg(args)}, {_quote_arg(body)})"
602
+ )
603
+ return "\n".join(transformed)
604
+
605
+ if not protected and stripped.startswith("%"):
606
+ folded, consumed = _fold_continuations(lines, index)
607
+ folded_stripped = folded.lstrip()
608
+ folded_indent = folded[: len(folded) - len(folded_stripped)]
609
+ name, args = _split_magic_head(folded_stripped[1:])
610
+ transformed.append(f"{folded_indent}__senpi_magic({_quote_arg(name)}, {_quote_arg(args)})")
611
+ index += consumed
612
+ continue
613
+
614
+ if not protected and stripped.startswith("!"):
615
+ folded, consumed = _fold_continuations(lines, index)
616
+ folded_stripped = folded.lstrip()
617
+ folded_indent = folded[: len(folded) - len(folded_stripped)]
618
+ command = folded_stripped[1:].strip()
619
+ transformed.append(f"{folded_indent}__senpi_shell({_quote_arg(command)})")
620
+ index += consumed
621
+ continue
622
+
623
+ if not protected:
624
+ assignment = _ASSIGN_LINE_RE.match(line)
625
+ if assignment:
626
+ right_hand_side = assignment.group("rhs").strip()
627
+ if right_hand_side.startswith("!"):
628
+ command = right_hand_side[1:].strip()
629
+ transformed.append(
630
+ f"{assignment.group('indent')}{assignment.group('lhs').rstrip()} = "
631
+ f"__senpi_shell({_quote_arg(command)})"
632
+ )
633
+ index += 1
634
+ continue
635
+ if right_hand_side.startswith("%") and not right_hand_side.startswith("%%"):
636
+ name, args = _split_magic_head(right_hand_side[1:])
637
+ transformed.append(
638
+ f"{assignment.group('indent')}{assignment.group('lhs').rstrip()} = "
639
+ f"__senpi_magic({_quote_arg(name)}, {_quote_arg(args)})"
640
+ )
641
+ index += 1
642
+ continue
643
+
644
+ transformed.append(line)
645
+ triple_quote = _advance_triple_quote_state(line, triple_quote)
646
+ index += 1
647
+ return "\n".join(transformed)
648
+
649
+
650
+ def _magic_cd(args: str) -> str:
651
+ path = os.path.expanduser(args.strip()) or os.path.expanduser("~")
652
+ os.chdir(path)
653
+ cwd = os.getcwd()
654
+ emit_status("cd", path=cwd)
655
+ return cwd
656
+
657
+
658
+ def _magic_env(args: str) -> Any:
659
+ stripped = args.strip()
660
+ if not stripped:
661
+ return env()
662
+ if "=" in stripped:
663
+ key, value = stripped.split("=", 1)
664
+ return env(key.strip(), value.strip())
665
+ return env(stripped)
666
+
667
+
668
+ _LINE_MAGICS: dict[str, Callable[[str], Any]] = {
669
+ "cd": _magic_cd,
670
+ "env": _magic_env,
671
+ }
672
+
673
+
674
+ def _magic(name: str, args: str) -> Any:
675
+ handler = _LINE_MAGICS.get(name)
676
+ if handler is None:
677
+ raise PreludeRuntimeError(f"Unsupported line magic: %{name}")
678
+ return handler(args)
679
+
680
+
681
+ def _magic_cell(name: str, args: str, body: str) -> Any:
682
+ if name in ("bash", "sh"):
683
+ command = "\n".join(part for part in (args, body) if part)
684
+ return _shell(command)
685
+ raise PreludeRuntimeError(f"Unsupported cell magic: %%{name}")
686
+
687
+
688
+ def _take_prefix_by_lines(value: str, max_lines: int) -> str:
689
+ if max_lines <= 0:
690
+ return ""
691
+ cursor = 0
692
+ for _ in range(max_lines):
693
+ newline = value.find("\n", cursor)
694
+ if newline < 0:
695
+ return value
696
+ cursor = newline + 1
697
+ return value[:cursor]
698
+
699
+
700
+ def _take_prefix_by_encoded_bytes(value: str, max_bytes: int, encoding: str) -> str:
701
+ if max_bytes <= 0:
702
+ return ""
703
+ if len(value.encode(encoding, errors="replace")) <= max_bytes:
704
+ return value
705
+ low = 0
706
+ high = len(value)
707
+ while low < high:
708
+ middle = (low + high + 1) // 2
709
+ if len(value[:middle].encode(encoding, errors="replace")) <= max_bytes:
710
+ low = middle
711
+ else:
712
+ high = middle - 1
713
+ return value[:low]
714
+
715
+
716
+ class _ShellOutputLimiter:
717
+ def __init__(self, *, max_bytes: int, max_lines: int, encoding: str) -> None:
718
+ self._remaining_bytes = max_bytes
719
+ self._remaining_lines = max_lines
720
+ self._encoding = encoding
721
+ self._truncated = False
722
+ self._at_line_start = True
723
+
724
+ def write(self, value: str) -> None:
725
+ if not value or self._truncated:
726
+ return
727
+ line_limited = _take_prefix_by_lines(value, self._remaining_lines)
728
+ truncated = line_limited != value
729
+ byte_limited = _take_prefix_by_encoded_bytes(
730
+ line_limited,
731
+ self._remaining_bytes,
732
+ self._encoding,
733
+ )
734
+ truncated = truncated or byte_limited != line_limited
735
+ if byte_limited:
736
+ text("stdout", byte_limited)
737
+ self._remaining_bytes -= len(
738
+ byte_limited.encode(self._encoding, errors="replace")
739
+ )
740
+ self._remaining_lines -= byte_limited.count("\n")
741
+ self._at_line_start = byte_limited.endswith("\n")
742
+ if truncated:
743
+ self._emit_truncation_notice()
744
+
745
+ def _emit_truncation_notice(self) -> None:
746
+ if self._truncated:
747
+ return
748
+ prefix = "" if self._at_line_start else "\n"
749
+ text("stdout", prefix + _SHELL_TRUNCATION_NOTICE)
750
+ self._truncated = True
751
+
752
+
753
+ class _BoundedTextCapture:
754
+ def __init__(self, max_bytes: int, max_lines: int, encoding: str) -> None:
755
+ self._remaining_bytes = max_bytes
756
+ self._remaining_lines = max_lines
757
+ self._encoding = encoding
758
+ self._parts: list[str] = []
759
+
760
+ def add(self, value: str) -> None:
761
+ if self._remaining_bytes <= 0 or self._remaining_lines <= 0:
762
+ return
763
+ line_limited = _take_prefix_by_lines(value, self._remaining_lines)
764
+ part = _take_prefix_by_encoded_bytes(
765
+ line_limited,
766
+ self._remaining_bytes,
767
+ self._encoding,
768
+ )
769
+ if not part:
770
+ return
771
+ self._parts.append(part)
772
+ self._remaining_bytes -= len(part.encode(self._encoding, errors="replace"))
773
+ self._remaining_lines -= part.count("\n")
774
+
775
+ def value(self) -> str:
776
+ return "".join(self._parts)
777
+
778
+
779
+ class ShellResult(list[str]):
780
+ def __init__(self, lines: list[str], returncode: int) -> None:
781
+ super().__init__(lines)
782
+ self.returncode = returncode
783
+
784
+ @property
785
+ def n(self) -> str:
786
+ return "\n".join(self)
787
+
788
+ @property
789
+ def s(self) -> str:
790
+ return " ".join(self)
791
+
792
+
793
+ def _shell(command: str) -> ShellResult:
794
+ process = subprocess.Popen(
795
+ command,
796
+ shell=True,
797
+ stdout=subprocess.PIPE,
798
+ stderr=subprocess.STDOUT,
799
+ )
800
+ if process.stdout is None:
801
+ return ShellResult([], process.wait())
802
+
803
+ encoding = locale.getpreferredencoding(False) or "utf-8"
804
+ decoder = codecs.getincrementaldecoder(encoding)(errors="replace")
805
+ limiter = _ShellOutputLimiter(
806
+ max_bytes=_SHELL_CAPTURE_MAX_BYTES,
807
+ max_lines=_SHELL_CAPTURE_MAX_LINES,
808
+ encoding=encoding,
809
+ )
810
+ capture = _BoundedTextCapture(
811
+ _SHELL_CAPTURE_MAX_BYTES,
812
+ _SHELL_CAPTURE_MAX_LINES,
813
+ encoding,
814
+ )
815
+
816
+ def consume(chunk_text: str) -> None:
817
+ if not chunk_text:
818
+ return
819
+ limiter.write(chunk_text)
820
+ capture.add(chunk_text)
821
+
822
+ while True:
823
+ raw_chunk = os.read(process.stdout.fileno(), _SHELL_READ_CHUNK_BYTES)
824
+ if not raw_chunk:
825
+ break
826
+ consume(decoder.decode(raw_chunk))
827
+ consume(decoder.decode(b"", final=True))
828
+ return ShellResult(capture.value().splitlines(), process.wait())
829
+
830
+
831
+ USER_NS.update(
832
+ {
833
+ "display": display,
834
+ "print": print,
835
+ "log": log,
836
+ "phase": phase,
837
+ "env": env,
838
+ "read": read,
839
+ "write": write,
840
+ "parallel": parallel,
841
+ "pipeline": pipeline,
842
+ "tool": tool,
843
+ "completion": completion,
844
+ "agent": agent,
845
+ "output": output,
846
+ "__senpi_magic": _magic,
847
+ "__senpi_magic_cell": _magic_cell,
848
+ "__senpi_shell": _shell,
849
+ }
850
+ )
851
+
852
+ TLA_FLAG = getattr(ast, "PyCF_ALLOW_TOP_LEVEL_AWAIT", 0x2000)
853
+
854
+
855
+ def compile_cell(source: str) -> tuple[Any | None, Any | None]:
856
+ module = ast.parse(transform_cell(source), mode="exec")
857
+ if not module.body:
858
+ return None, None
859
+ last = module.body[-1]
860
+ if isinstance(last, ast.Expr):
861
+ body = ast.Module(body=module.body[:-1], type_ignores=[])
862
+ expression = ast.Expression(body=last.value)
863
+ ast.copy_location(expression, last)
864
+ return compile(body, "<cell>", "exec", flags=TLA_FLAG), compile(
865
+ expression,
866
+ "<cell>",
867
+ "eval",
868
+ flags=TLA_FLAG,
869
+ )
870
+ return compile(module, "<cell>", "exec", flags=TLA_FLAG), None
871
+
872
+
873
+ async def run_code(code: Any, want_value: bool) -> Any:
874
+ if code is None:
875
+ return None
876
+ if code.co_flags & inspect.CO_COROUTINE:
877
+ result = await eval(code, USER_NS)
878
+ return result if want_value else None
879
+ if want_value:
880
+ return eval(code, USER_NS)
881
+ exec(code, USER_NS)
882
+ return None
883
+
884
+
885
+ def run_cell(cell_id: str, code: str) -> None:
886
+ start = time.monotonic()
887
+ stdout = io.StringIO()
888
+ stderr = io.StringIO()
889
+ try:
890
+ with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
891
+ body, expression = compile_cell(code)
892
+ LOOP.run_until_complete(run_code(body, False))
893
+ value = LOOP.run_until_complete(run_code(expression, True))
894
+ text("stdout", stdout.getvalue())
895
+ text("stderr", stderr.getvalue())
896
+ result: dict[str, Any] = {
897
+ "type": "result",
898
+ "cellId": cell_id,
899
+ "ok": True,
900
+ "durationMs": elapsed(start),
901
+ }
902
+ if value is not None:
903
+ result["valueRepr"] = repr(value)
904
+ emit(result)
905
+ except BaseException as exc: # noqa: BROAD_EXCEPT_OK — cell boundary serializes user errors and interrupts.
906
+ text("stdout", stdout.getvalue())
907
+ text("stderr", stderr.getvalue())
908
+ emit(
909
+ {
910
+ "type": "result",
911
+ "cellId": cell_id,
912
+ "ok": False,
913
+ "error": bridge_error(exc),
914
+ "durationMs": elapsed(start),
915
+ }
916
+ )
917
+
918
+
919
+ def elapsed(start: float) -> int:
920
+ return max(0, int((time.monotonic() - start) * 1000))
921
+
922
+
923
+ def handle(message: dict[str, Any]) -> bool:
924
+ global SESSION_ID, CONNECTION
925
+ message_type = message.get("type")
926
+ if message_type == "init":
927
+ SESSION_ID = str(message.get("sessionId", ""))
928
+ connection = message.get("connection")
929
+ if not isinstance(connection, dict):
930
+ emit({"type": "init-failed", "error": {"message": "missing bridge connection"}})
931
+ return True
932
+ CONNECTION = connection
933
+ emit({"type": "ready"})
934
+ return True
935
+ if message_type == "run":
936
+ run_cell(str(message.get("cellId", "")), str(message.get("code", "")))
937
+ return True
938
+ if message_type == "close":
939
+ emit({"type": "closed"})
940
+ return False
941
+ return True
942
+
943
+
944
+ def main() -> None:
945
+ for raw in sys.stdin:
946
+ try:
947
+ if not handle(json.loads(raw)):
948
+ break
949
+ except BaseException as exc: # noqa: BROAD_EXCEPT_OK — process boundary serializes malformed input and interrupts.
950
+ emit({"type": "init-failed", "error": bridge_error(exc)})
951
+
952
+
953
+ if __name__ == "__main__":
954
+ main()