stcode 0.0.1a0__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 (111) hide show
  1. stcode/__init__.py +10 -0
  2. stcode/cli/__init__.py +9 -0
  3. stcode/cli/bootstrap.py +70 -0
  4. stcode/cli/labels.py +621 -0
  5. stcode/cli/logic/__init__.py +0 -0
  6. stcode/cli/logic/commands.py +36 -0
  7. stcode/cli/logic/completion.py +43 -0
  8. stcode/cli/logic/connection.py +46 -0
  9. stcode/cli/logic/requests.py +18 -0
  10. stcode/cli/logic/sessions.py +67 -0
  11. stcode/cli/logic/settings.py +35 -0
  12. stcode/cli/logic/state.py +43 -0
  13. stcode/cli/logic/workspace.py +17 -0
  14. stcode/cli/main.py +290 -0
  15. stcode/cli/models.py +61 -0
  16. stcode/cli/services/__init__.py +0 -0
  17. stcode/cli/services/client.py +393 -0
  18. stcode/cli/services/launcher.py +159 -0
  19. stcode/cli/services/paths.py +18 -0
  20. stcode/cli/services/preferences.py +107 -0
  21. stcode/cli/ui/__init__.py +0 -0
  22. stcode/cli/ui/app.py +136 -0
  23. stcode/cli/ui/app.tcss +3 -0
  24. stcode/cli/ui/components/__init__.py +0 -0
  25. stcode/cli/ui/components/banner.py +78 -0
  26. stcode/cli/ui/components/cards.py +229 -0
  27. stcode/cli/ui/components/prompt.py +225 -0
  28. stcode/cli/ui/components/status.py +95 -0
  29. stcode/cli/ui/components/transcript/__init__.py +51 -0
  30. stcode/cli/ui/components/transcript/entries.py +232 -0
  31. stcode/cli/ui/components/transcript/formatting.py +188 -0
  32. stcode/cli/ui/components/transcript/view.py +101 -0
  33. stcode/cli/ui/screens/__init__.py +0 -0
  34. stcode/cli/ui/screens/chat/__init__.py +0 -0
  35. stcode/cli/ui/screens/chat/chat.tcss +12 -0
  36. stcode/cli/ui/screens/chat/presenter.py +187 -0
  37. stcode/cli/ui/screens/chat/screen.py +682 -0
  38. stcode/cli/ui/screens/connect.py +126 -0
  39. stcode/cli/ui/screens/connect.tcss +49 -0
  40. stcode/cli/ui/screens/dialog.tcss +40 -0
  41. stcode/cli/ui/screens/sessions.py +105 -0
  42. stcode/cli/ui/screens/settings.py +137 -0
  43. stcode/cli/ui/screens/settings.tcss +69 -0
  44. stcode/cli/ui/screens/trust.py +50 -0
  45. stcode/cli/ui/theme.py +221 -0
  46. stcode/core/__main__.py +7 -0
  47. stcode/core/agent/__init__.py +41 -0
  48. stcode/core/agent/agent.py +651 -0
  49. stcode/core/agent/events.py +97 -0
  50. stcode/core/agent/supervisor.py +226 -0
  51. stcode/core/agent/task.py +120 -0
  52. stcode/core/common/__init__.py +21 -0
  53. stcode/core/common/compat.py +27 -0
  54. stcode/core/common/ids.py +43 -0
  55. stcode/core/common/tools.py +50 -0
  56. stcode/core/common/trace.py +406 -0
  57. stcode/core/common/truncate.py +76 -0
  58. stcode/core/configs.py +490 -0
  59. stcode/core/daemon/__init__.py +32 -0
  60. stcode/core/daemon/autonomy.py +71 -0
  61. stcode/core/daemon/main.py +126 -0
  62. stcode/core/daemon/protocol.py +499 -0
  63. stcode/core/daemon/runner.py +359 -0
  64. stcode/core/daemon/server.py +682 -0
  65. stcode/core/daemon/workspace.py +88 -0
  66. stcode/core/harness/__init__.py +81 -0
  67. stcode/core/harness/approvals.py +85 -0
  68. stcode/core/harness/context.py +260 -0
  69. stcode/core/harness/errors.py +40 -0
  70. stcode/core/harness/harness.py +417 -0
  71. stcode/core/harness/mcp.py +530 -0
  72. stcode/core/harness/outputs.py +99 -0
  73. stcode/core/harness/prompts/__init__.py +138 -0
  74. stcode/core/harness/prompts/execute.py +51 -0
  75. stcode/core/harness/prompts/plan.py +57 -0
  76. stcode/core/harness/prompts/sections.py +238 -0
  77. stcode/core/harness/skills/__init__.py +30 -0
  78. stcode/core/harness/skills/loader.py +247 -0
  79. stcode/core/harness/tools/__init__.py +111 -0
  80. stcode/core/harness/tools/base.py +640 -0
  81. stcode/core/harness/tools/files.py +276 -0
  82. stcode/core/harness/tools/interact.py +72 -0
  83. stcode/core/harness/tools/registry.py +113 -0
  84. stcode/core/harness/tools/repl.py +97 -0
  85. stcode/core/harness/tools/schema.py +186 -0
  86. stcode/core/harness/tools/search.py +356 -0
  87. stcode/core/harness/tools/shell.py +372 -0
  88. stcode/core/harness/tools/skill.py +66 -0
  89. stcode/core/harness/tools/todo.py +62 -0
  90. stcode/core/harness/tools/web.py +163 -0
  91. stcode/core/providers/__init__.py +31 -0
  92. stcode/core/providers/anthropic_claude.py +243 -0
  93. stcode/core/providers/base.py +73 -0
  94. stcode/core/providers/gateway.py +267 -0
  95. stcode/core/providers/google_gemini.py +225 -0
  96. stcode/core/providers/openai_gpt.py +250 -0
  97. stcode/core/providers/registry.py +46 -0
  98. stcode/core/providers/types.py +148 -0
  99. stcode/core/repl/__init__.py +19 -0
  100. stcode/core/repl/_worker.py +179 -0
  101. stcode/core/repl/client.py +374 -0
  102. stcode/core/session/__init__.py +39 -0
  103. stcode/core/session/session.py +493 -0
  104. stcode/core/team/__init__.py +38 -0
  105. stcode/core/team/mailbox.py +249 -0
  106. stcode/core/team/tools.py +145 -0
  107. stcode-0.0.1a0.dist-info/METADATA +229 -0
  108. stcode-0.0.1a0.dist-info/RECORD +111 -0
  109. stcode-0.0.1a0.dist-info/WHEEL +4 -0
  110. stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
  111. stcode-0.0.1a0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,179 @@
1
+ """
2
+ The REPL subprocess. Run as `python -u _worker.py`.
3
+
4
+ One line in, one or more lines out.
5
+
6
+ in {"type":"exec","id":"c1","code":"x = 1\nprint(x)"}
7
+ out {"type":"chunk","text":"1"} <- while the cell runs
8
+ out {"type":"result","id":"c1","ok":true,"stdout":"1\n","error":null}
9
+
10
+ in {"type":"inject","id":"i1","values":{"read_ab12": "..."}}
11
+ out {"type":"ok","id":"i1"}
12
+
13
+ `ns` persists between cells. `tool_out` lives in `ns` and `inject` fills it, so the
14
+ elided half of a tool result really is reachable. Top-level `await` works, via
15
+ `PyCF_ALLOW_TOP_LEVEL_AWAIT`.
16
+
17
+ Dependency-free and imported by nothing: it is spawned, not imported.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import ast
23
+ import asyncio
24
+ import contextlib
25
+ import io
26
+ import json
27
+ import sys
28
+ import traceback
29
+ from typing import Any
30
+
31
+ # The real stdout, grabbed before any redirect. Cell output is redirected away from
32
+ # sys.stdout; this handle is the protocol and must never be.
33
+ _PROTO = sys.stdout
34
+ _REAL_STDERR = sys.stderr
35
+
36
+ MAX_FRAME_CHARS = 32768
37
+ """Cap on any single string this worker puts on the wire.
38
+
39
+ The parent reads one JSON object per line through an `asyncio` stream, which refuses a
40
+ line past its buffer limit — and once that happens the pipe is wedged, not merely
41
+ noisy. A cell printing a megabyte is a cell whose author wanted a variable, so the cap
42
+ belongs here, at the producer, rather than in a bigger buffer over there."""
43
+
44
+ HEAD_SHARE = 0.75
45
+ """A traceback's last line and a test summary both live at the end."""
46
+
47
+ ns: dict[str, Any] = {"tool_out": {}}
48
+
49
+
50
+ def cap(text: str, limit: int = MAX_FRAME_CHARS) -> str:
51
+ """Keep the head and tail of `text`, and say what went missing.
52
+
53
+ Deliberately a copy of `core/common/truncate.elide` rather than an import: this
54
+ file is spawned, never imported, and must stay dependency-free.
55
+ """
56
+ if len(text) <= limit:
57
+ return text
58
+ marker = f"\n… [{len(text) - limit:,} chars dropped by the REPL — print less, or slice the variable] …\n"
59
+ body = max(limit - len(marker), 0)
60
+ head = int(body * HEAD_SHARE)
61
+ return text[:head] + marker + (text[-(body - head):] if body - head else "")
62
+
63
+ _loop = asyncio.new_event_loop()
64
+ asyncio.set_event_loop(_loop)
65
+
66
+
67
+ def emit(message: dict[str, Any]) -> None:
68
+ """One JSON object, one line, flushed."""
69
+ _PROTO.write(json.dumps(message, ensure_ascii=False, default=str) + "\n")
70
+ _PROTO.flush()
71
+
72
+
73
+ class _Tee(io.TextIOBase):
74
+ """Collects the cell's output, and streams it out a line at a time.
75
+
76
+ Two readers want the same bytes for different reasons: the caller wants the whole
77
+ thing at the end, the UI wants it now so a 90-second cell does not look frozen.
78
+ Streaming per line rather than per `write()` because `print` makes several writes
79
+ per line and one frame each would be noise.
80
+ """
81
+
82
+ def __init__(self) -> None:
83
+ self.buffer = io.StringIO()
84
+ self._pending = ""
85
+
86
+ def write(self, text: str) -> int:
87
+ self.buffer.write(text)
88
+ self._pending += text
89
+ while "\n" in self._pending:
90
+ line, self._pending = self._pending.split("\n", 1)
91
+ emit({"type": "chunk", "text": cap(line)})
92
+ return len(text)
93
+
94
+ def flush(self) -> None:
95
+ pass
96
+
97
+ def fileno(self) -> int:
98
+ """The real stderr's fd.
99
+
100
+ Needed because `subprocess.Popen` asks for one, and a cell that spawns a
101
+ process is not exotic — launching a stdio MCP server is the main thing this
102
+ REPL exists for. Without this, `redirect_stderr` turns every such call into
103
+ `io.UnsupportedOperation: fileno`.
104
+
105
+ A child's output therefore goes to the worker's own stderr rather than into
106
+ this buffer. The parent drains that continuously, so a chatty server cannot
107
+ fill the pipe and deadlock.
108
+ """
109
+ return _REAL_STDERR.fileno()
110
+
111
+ def drain(self) -> None:
112
+ """Emit the last line when it had no trailing newline."""
113
+ if self._pending:
114
+ emit({"type": "chunk", "text": cap(self._pending)})
115
+ self._pending = ""
116
+
117
+
118
+ def run_cell(code: str) -> tuple[bool, str, str | None]:
119
+ """Execute one cell. Returns (ok, stdout, traceback)."""
120
+ tee = _Tee()
121
+ ok, error = True, None
122
+ try:
123
+ with contextlib.redirect_stdout(tee), contextlib.redirect_stderr(tee):
124
+ compiled = compile(code, "<cell>", "exec", flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT)
125
+ # `eval` of exec-mode code returns None, unless the cell had a top-level
126
+ # await — then it is a coroutine and we drive it here.
127
+ pending = eval(compiled, ns) # noqa: S307 — running the model's code is the job
128
+ if pending is not None:
129
+ _loop.run_until_complete(pending)
130
+ except BaseException: # noqa: BLE001 — KeyboardInterrupt is an outcome, not a crash
131
+ ok, error = False, traceback.format_exc()
132
+ tee.drain()
133
+ return ok, tee.buffer.getvalue(), error
134
+
135
+
136
+ def main() -> None:
137
+ while True:
138
+ try:
139
+ line = sys.stdin.readline()
140
+ except KeyboardInterrupt:
141
+ # A SIGINT that landed after the cell had already returned. Nothing to
142
+ # interrupt; going back to waiting is the correct response.
143
+ continue
144
+ if not line:
145
+ return # stdin closed — the parent is gone
146
+ line = line.strip()
147
+ if not line:
148
+ continue
149
+
150
+ try:
151
+ request = json.loads(line)
152
+ except json.JSONDecodeError:
153
+ continue
154
+ if not isinstance(request, dict):
155
+ continue
156
+
157
+ kind = request.get("type", "exec")
158
+ request_id = str(request.get("id", ""))
159
+
160
+ if kind == "inject":
161
+ ns["tool_out"].update(request.get("values") or {})
162
+ emit({"type": "ok", "id": request_id})
163
+ continue
164
+ if kind == "ping":
165
+ emit({"type": "ok", "id": request_id})
166
+ continue
167
+
168
+ ok, stdout, error = run_cell(str(request.get("code", "")))
169
+ emit({
170
+ "type": "result",
171
+ "id": request_id,
172
+ "ok": ok,
173
+ "stdout": cap(stdout),
174
+ "error": cap(error) if error else error,
175
+ })
176
+
177
+
178
+ if __name__ == "__main__":
179
+ main()
@@ -0,0 +1,374 @@
1
+ """
2
+ PyREPL — the parent half of the REPL. Owns one `python -u` subprocess.
3
+
4
+ repl = PyREPL(cwd=project_root)
5
+ result = await repl.execute("x = load()\nprint(len(x))")
6
+ await repl.inject({"read_ab12": "...the elided remainder..."})
7
+ await repl.aclose()
8
+
9
+ A subprocess rather than `exec()` here, because a model that writes `while True:` would
10
+ otherwise hang the whole daemon, and in a container nobody can press Ctrl-C. Here it
11
+ costs one SIGINT.
12
+
13
+ * **One cell at a time** — the namespace is shared state, and two concurrent cells in it
14
+ is a race with no upside.
15
+ * **Start on first use.** Most sessions never call `repl`.
16
+ * **A dead worker is replaced, not mourned** — killed, and the next call gets a fresh
17
+ one with an empty namespace, which the caller is told about.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import contextlib
24
+ import json
25
+ import os
26
+ import signal
27
+ import sys
28
+ import uuid
29
+ from collections import deque
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+ from typing import Any, Awaitable, Callable
33
+
34
+ from stcode.core.common.truncate import DEFAULT_VIEW_LIMIT, NARROW_REQUEST_HINT, elide
35
+
36
+ WORKER = Path(__file__).with_name("_worker.py")
37
+
38
+ DEFAULT_TIMEOUT = 120.0
39
+ STDERR_TAIL_LINES = 50
40
+ """Worker stderr kept for a crash report. Bounded — the alternative is holding every
41
+ warning a long session produced."""
42
+
43
+ STREAM_LIMIT = 4 * 1024 * 1024
44
+ """Bytes one protocol line may take. Belt to the worker's braces: it caps what it emits
45
+ at 32k chars, but a char is up to four bytes, and `asyncio`'s 64 KiB default does not
46
+ merely truncate an over-long line — the read raises and the paused transport then never
47
+ delivers EOF, so even `aclose()` hangs."""
48
+
49
+ INTERRUPT_GRACE = 5.0
50
+ """How long the worker gets to report back after SIGINT before it is killed. A cell
51
+ stuck in a C call cannot be interrupted at all, and waiting forever for one is how a
52
+ daemon becomes something you `kill -9` instead of trusting."""
53
+
54
+ StreamFn = Callable[[str, str], Awaitable[None]]
55
+ """`(stream_name, text) -> None`. Called per line while a cell runs."""
56
+
57
+
58
+ @dataclass
59
+ class ExecResult:
60
+ """What one cell produced."""
61
+
62
+ ok: bool = True
63
+ outcome: str = "ok"
64
+ """`ok` | `error` | `timeout` | `crashed`. The model needs these apart: timeout means
65
+ make it smaller, error means fix it, crashed means the namespace is gone."""
66
+
67
+ stdout: str = ""
68
+ error: str | None = None
69
+ duration: float = 0.0
70
+ metadata: dict[str, Any] = field(default_factory=dict)
71
+
72
+ def text(self) -> str:
73
+ """Output and traceback, in the order they happened."""
74
+ parts = [part for part in (self.stdout.rstrip("\n"), self.error) if part]
75
+ return "\n".join(parts)
76
+
77
+ def view(self, limit: int = DEFAULT_VIEW_LIMIT) -> str:
78
+ """What the model reads. Elided at `limit`, never summarised."""
79
+ return elide(self.text(), limit, hint=NARROW_REQUEST_HINT)
80
+
81
+
82
+ class ReplError(RuntimeError):
83
+ """The worker could not be started or could not be reached."""
84
+
85
+
86
+ class PyREPL:
87
+ """A persistent Python namespace in a subprocess."""
88
+
89
+ def __init__(
90
+ self,
91
+ *,
92
+ cwd: str | Path | None = None,
93
+ env: dict[str, str] | None = None,
94
+ python: str = sys.executable,
95
+ ) -> None:
96
+ self.cwd = Path(cwd) if cwd else Path.cwd()
97
+ self.env = env or {}
98
+ self.python = python
99
+ self._process: asyncio.subprocess.Process | None = None
100
+ self._lock = asyncio.Lock()
101
+ self._stderr_task: asyncio.Task[None] | None = None
102
+ self._stderr_tail: deque[str] = deque(maxlen=STDERR_TAIL_LINES)
103
+ # Injections that arrived before the worker started. Held rather than spawning
104
+ # a Python for them — the session may never use one.
105
+ self._pending_injects: dict[str, Any] = {}
106
+
107
+ @property
108
+ def running(self) -> bool:
109
+ return self._process is not None and self._process.returncode is None
110
+
111
+ # ---- lifecycle ----
112
+
113
+ async def start(self) -> None:
114
+ """Spawn the worker. Idempotent."""
115
+ if self.running:
116
+ return
117
+ environment = {**os.environ, **self.env}
118
+ # The generated MCP stubs live under the workspace, so it must be importable
119
+ # from inside a cell — this is what makes `from mcp_servers.github import ...`
120
+ # work.
121
+ roots = [str(self.cwd), str(self.cwd / ".stcode")]
122
+ existing = environment.get("PYTHONPATH", "")
123
+ environment["PYTHONPATH"] = os.pathsep.join([*roots, existing]) if existing else os.pathsep.join(roots)
124
+
125
+ try:
126
+ self._process = await asyncio.create_subprocess_exec(
127
+ self.python, "-u", str(WORKER),
128
+ stdin=asyncio.subprocess.PIPE,
129
+ stdout=asyncio.subprocess.PIPE,
130
+ stderr=asyncio.subprocess.PIPE,
131
+ cwd=str(self.cwd),
132
+ env=environment,
133
+ limit=STREAM_LIMIT,
134
+ )
135
+ except OSError as exc:
136
+ raise ReplError(f"Could not start the REPL worker: {exc}") from exc
137
+
138
+ self._stderr_task = asyncio.create_task(self._drain_stderr(), name="stcode-repl-stderr")
139
+
140
+ if self._pending_injects:
141
+ values, self._pending_injects = self._pending_injects, {}
142
+ await self._request({"type": "inject", "values": values})
143
+
144
+ async def _drain_stderr(self) -> None:
145
+ """Keep the worker's stderr pipe empty, and keep the last of it.
146
+
147
+ The second half is the one that bites: a subprocess started from a cell inherits
148
+ this pipe, and a chatty child blocks on a full buffer and hangs its own cell.
149
+ """
150
+ process = self._process
151
+ if process is None or process.stderr is None:
152
+ return
153
+ with contextlib.suppress(Exception):
154
+ while True:
155
+ line = await process.stderr.readline()
156
+ if not line:
157
+ return
158
+ self._stderr_tail.append(line.decode("utf-8", errors="replace").rstrip("\n"))
159
+
160
+ async def aclose(self) -> None:
161
+ """Close stdin, then kill if it does not leave."""
162
+ process = self._process
163
+ self._process = None
164
+ if self._stderr_task is not None:
165
+ self._stderr_task.cancel()
166
+ with contextlib.suppress(asyncio.CancelledError, Exception):
167
+ await self._stderr_task
168
+ self._stderr_task = None
169
+ if process is None or process.returncode is not None:
170
+ return
171
+ with contextlib.suppress(Exception):
172
+ if process.stdin is not None:
173
+ process.stdin.close()
174
+ try:
175
+ await asyncio.wait_for(process.wait(), 2.0)
176
+ except Exception:
177
+ with contextlib.suppress(ProcessLookupError, OSError):
178
+ process.kill()
179
+ # Bounded, because `wait()` is not: it completes only once every pipe has
180
+ # reported EOF, and a stdout transport paused by an over-long line never
181
+ # will. An unbounded wait here is how closing a session hangs forever.
182
+ with contextlib.suppress(Exception):
183
+ await asyncio.wait_for(process.wait(), 2.0)
184
+
185
+ async def __aenter__(self) -> "PyREPL":
186
+ await self.start()
187
+ return self
188
+
189
+ async def __aexit__(self, *_exc_info: object) -> None:
190
+ await self.aclose()
191
+
192
+ # ---- the two verbs ----
193
+
194
+ async def execute(
195
+ self,
196
+ code: str,
197
+ *,
198
+ timeout: float = DEFAULT_TIMEOUT,
199
+ on_stream: StreamFn | None = None,
200
+ ) -> ExecResult:
201
+ """Run one cell and return what it printed."""
202
+ async with self._lock:
203
+ await self.start()
204
+ started = asyncio.get_running_loop().time()
205
+ request_id = uuid.uuid4().hex[:8]
206
+ try:
207
+ reply = await self._exchange(
208
+ {"type": "exec", "id": request_id, "code": code},
209
+ timeout=timeout,
210
+ on_stream=on_stream,
211
+ )
212
+ except asyncio.TimeoutError:
213
+ return await self._after_timeout(request_id, timeout, started, on_stream)
214
+ except ReplError as exc:
215
+ await self.aclose()
216
+ return ExecResult(
217
+ ok=False,
218
+ outcome="crashed",
219
+ error=f"{exc}\nThe namespace is gone; the next cell starts a fresh one.",
220
+ duration=asyncio.get_running_loop().time() - started,
221
+ )
222
+
223
+ return _result_from(reply, asyncio.get_running_loop().time() - started)
224
+
225
+ async def inject(self, values: dict[str, Any]) -> None:
226
+ """Push values into the cell namespace's `tool_out`.
227
+
228
+ What `elide` cut is parked here, so the hint naming `tool_out["..."]` is true.
229
+ """
230
+ if not values:
231
+ return
232
+ if not self.running:
233
+ self._pending_injects.update(values)
234
+ return
235
+ async with self._lock:
236
+ with contextlib.suppress(ReplError, asyncio.TimeoutError):
237
+ await self._exchange({"type": "inject", "values": values}, timeout=10.0)
238
+
239
+ async def interrupt(self) -> None:
240
+ """SIGINT the cell in flight. The namespace survives."""
241
+ if self.running and self._process is not None:
242
+ with contextlib.suppress(ProcessLookupError, OSError):
243
+ self._process.send_signal(signal.SIGINT)
244
+
245
+ # ---- wire ----
246
+
247
+ async def _request(self, message: dict[str, Any]) -> dict[str, Any]:
248
+ return await self._exchange(message, timeout=10.0)
249
+
250
+ async def _exchange(
251
+ self,
252
+ message: dict[str, Any],
253
+ *,
254
+ timeout: float | None,
255
+ on_stream: StreamFn | None = None,
256
+ ) -> dict[str, Any]:
257
+ """Send one message, read until its answer. Forwards `chunk` lines on the way."""
258
+ process = self._process
259
+ if process is None or process.stdin is None or process.stdout is None:
260
+ raise ReplError("the REPL worker is not running")
261
+
262
+ message.setdefault("id", uuid.uuid4().hex[:8])
263
+ try:
264
+ process.stdin.write((json.dumps(message, default=str) + "\n").encode("utf-8"))
265
+ await process.stdin.drain()
266
+ except (ConnectionError, OSError, RuntimeError) as exc:
267
+ raise ReplError(f"the REPL worker stopped listening: {exc}") from exc
268
+
269
+ wanted = str(message["id"])
270
+ if timeout is None:
271
+ return await self._read_frame(on_stream=on_stream, wanted=wanted)
272
+ return await asyncio.wait_for(
273
+ self._read_frame(on_stream=on_stream, wanted=wanted), timeout
274
+ )
275
+
276
+ async def _read_frame(
277
+ self, *, on_stream: StreamFn | None = None, wanted: str | None = None
278
+ ) -> dict[str, Any]:
279
+ """Read until a real answer arrives. Chunks are forwarded, not returned.
280
+
281
+ `wanted` matches the request id. Without it a late frame — a cell answering its
282
+ SIGINT after we stopped waiting — would be handed to the *next* call, and every
283
+ reply after that would be off by one.
284
+ """
285
+ process = self._process
286
+ if process is None or process.stdout is None:
287
+ raise ReplError("the REPL worker is not running")
288
+ while True:
289
+ try:
290
+ line = await process.stdout.readline()
291
+ except (ValueError, asyncio.LimitOverrunError) as exc:
292
+ # A line past `STREAM_LIMIT`. The buffer is dropped and the pipe is no
293
+ # longer in sync with the protocol, so the worker is gone either way —
294
+ # say so as a `ReplError`, which `execute` already knows how to turn
295
+ # into a crashed result, rather than as a bare ValueError from a caller
296
+ # that only ever asked to run a cell.
297
+ raise ReplError(
298
+ f"the REPL worker sent more than {STREAM_LIMIT:,} bytes on one line "
299
+ f"({exc}). Assign the value to a variable and print a slice of it."
300
+ ) from exc
301
+ if not line:
302
+ raise ReplError(await self._death_note(process))
303
+ try:
304
+ frame = json.loads(line.decode("utf-8", errors="replace"))
305
+ except json.JSONDecodeError:
306
+ continue
307
+ if frame.get("type") == "chunk":
308
+ if on_stream is not None:
309
+ await on_stream("stdout", str(frame.get("text", "")))
310
+ continue
311
+ if wanted and str(frame.get("id", "")) != wanted:
312
+ continue # a stale answer to a call that already gave up
313
+ return frame # type: ignore[no-any-return]
314
+
315
+ async def _after_timeout(
316
+ self, request_id: str, timeout: float, started: float, on_stream: StreamFn | None
317
+ ) -> ExecResult:
318
+ """Interrupt, then give the worker a moment to report what it had.
319
+
320
+ A cell that answers its SIGINT keeps its namespace, which is why this comes
321
+ before the kill. We wait for that cell's *own* result frame — anything else
322
+ would leave it in the pipe for the next call to trip over.
323
+ """
324
+ await self.interrupt()
325
+ elapsed = asyncio.get_running_loop().time() - started
326
+ try:
327
+ reply = await asyncio.wait_for(
328
+ self._read_frame(on_stream=on_stream, wanted=request_id), INTERRUPT_GRACE
329
+ )
330
+ except (asyncio.TimeoutError, ReplError):
331
+ await self.aclose()
332
+ return ExecResult(
333
+ ok=False,
334
+ outcome="timeout",
335
+ error=(
336
+ f"The cell exceeded {timeout:.0f}s and would not stop, so the "
337
+ "interpreter was killed. The namespace is gone. Split the work into "
338
+ "smaller cells."
339
+ ),
340
+ duration=elapsed,
341
+ )
342
+ return ExecResult(
343
+ ok=False,
344
+ outcome="timeout",
345
+ stdout=str(reply.get("stdout", "")),
346
+ error=(
347
+ f"The cell was interrupted after {timeout:.0f}s. The namespace survived, "
348
+ "so whatever it managed to build is still there to inspect."
349
+ ),
350
+ duration=elapsed,
351
+ )
352
+
353
+ async def _death_note(self, process: asyncio.subprocess.Process) -> str:
354
+ """Whatever the worker said on its way out — usually the real reason."""
355
+ detail = "\n".join(self._stderr_tail).strip()
356
+ code = process.returncode
357
+ return f"the REPL worker exited ({code}){': ' + detail[-800:] if detail else ''}"
358
+
359
+ def __repr__(self) -> str:
360
+ return f"PyREPL(cwd={self.cwd}, running={self.running})"
361
+
362
+
363
+ def _result_from(reply: dict[str, Any], duration: float) -> ExecResult:
364
+ ok = bool(reply.get("ok", False))
365
+ return ExecResult(
366
+ ok=ok,
367
+ outcome="ok" if ok else "error",
368
+ stdout=str(reply.get("stdout", "")),
369
+ error=reply.get("error"),
370
+ duration=duration,
371
+ )
372
+
373
+
374
+ __all__ = ["DEFAULT_TIMEOUT", "ExecResult", "PyREPL", "ReplError", "StreamFn"]
@@ -0,0 +1,39 @@
1
+ """
2
+ Session — the append-only JSONL transcript, and the only place a turn is recorded.
3
+
4
+ s = Session.create(cwd=workspace, role="backend-dev")
5
+ s = Session.resume("01K7...")
6
+ Session.list(limit=20)
7
+
8
+ s = Session.create(cwd=workspace, defer=True) # no file until the first message
9
+
10
+ s.append(type="user", content="Add rate limiting to the API")
11
+ s.set_meta(model="claude-opus-5") # a setting, appended — never a rewrite
12
+ s.messages() # -> list[Message] for the gateway
13
+ s.tail(30) # -> raw records for the supervisor
14
+ s.meta(), s.overrides() # what it started as / what changed since
15
+
16
+ One writer, one file, three readers. There is no second logger to keep in sync.
17
+ """
18
+
19
+ from stcode.core.session.session import (
20
+ DEFAULT_SESSION_DIR,
21
+ META_BOOKKEEPING,
22
+ MODEL_VISIBLE,
23
+ Session,
24
+ new_id,
25
+ prune,
26
+ read_records,
27
+ sessions_dir,
28
+ )
29
+
30
+ __all__ = [
31
+ "DEFAULT_SESSION_DIR",
32
+ "META_BOOKKEEPING",
33
+ "MODEL_VISIBLE",
34
+ "Session",
35
+ "new_id",
36
+ "prune",
37
+ "read_records",
38
+ "sessions_dir",
39
+ ]