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.
- stcode/__init__.py +10 -0
- stcode/cli/__init__.py +9 -0
- stcode/cli/bootstrap.py +70 -0
- stcode/cli/labels.py +621 -0
- stcode/cli/logic/__init__.py +0 -0
- stcode/cli/logic/commands.py +36 -0
- stcode/cli/logic/completion.py +43 -0
- stcode/cli/logic/connection.py +46 -0
- stcode/cli/logic/requests.py +18 -0
- stcode/cli/logic/sessions.py +67 -0
- stcode/cli/logic/settings.py +35 -0
- stcode/cli/logic/state.py +43 -0
- stcode/cli/logic/workspace.py +17 -0
- stcode/cli/main.py +290 -0
- stcode/cli/models.py +61 -0
- stcode/cli/services/__init__.py +0 -0
- stcode/cli/services/client.py +393 -0
- stcode/cli/services/launcher.py +159 -0
- stcode/cli/services/paths.py +18 -0
- stcode/cli/services/preferences.py +107 -0
- stcode/cli/ui/__init__.py +0 -0
- stcode/cli/ui/app.py +136 -0
- stcode/cli/ui/app.tcss +3 -0
- stcode/cli/ui/components/__init__.py +0 -0
- stcode/cli/ui/components/banner.py +78 -0
- stcode/cli/ui/components/cards.py +229 -0
- stcode/cli/ui/components/prompt.py +225 -0
- stcode/cli/ui/components/status.py +95 -0
- stcode/cli/ui/components/transcript/__init__.py +51 -0
- stcode/cli/ui/components/transcript/entries.py +232 -0
- stcode/cli/ui/components/transcript/formatting.py +188 -0
- stcode/cli/ui/components/transcript/view.py +101 -0
- stcode/cli/ui/screens/__init__.py +0 -0
- stcode/cli/ui/screens/chat/__init__.py +0 -0
- stcode/cli/ui/screens/chat/chat.tcss +12 -0
- stcode/cli/ui/screens/chat/presenter.py +187 -0
- stcode/cli/ui/screens/chat/screen.py +682 -0
- stcode/cli/ui/screens/connect.py +126 -0
- stcode/cli/ui/screens/connect.tcss +49 -0
- stcode/cli/ui/screens/dialog.tcss +40 -0
- stcode/cli/ui/screens/sessions.py +105 -0
- stcode/cli/ui/screens/settings.py +137 -0
- stcode/cli/ui/screens/settings.tcss +69 -0
- stcode/cli/ui/screens/trust.py +50 -0
- stcode/cli/ui/theme.py +221 -0
- stcode/core/__main__.py +7 -0
- stcode/core/agent/__init__.py +41 -0
- stcode/core/agent/agent.py +651 -0
- stcode/core/agent/events.py +97 -0
- stcode/core/agent/supervisor.py +226 -0
- stcode/core/agent/task.py +120 -0
- stcode/core/common/__init__.py +21 -0
- stcode/core/common/compat.py +27 -0
- stcode/core/common/ids.py +43 -0
- stcode/core/common/tools.py +50 -0
- stcode/core/common/trace.py +406 -0
- stcode/core/common/truncate.py +76 -0
- stcode/core/configs.py +490 -0
- stcode/core/daemon/__init__.py +32 -0
- stcode/core/daemon/autonomy.py +71 -0
- stcode/core/daemon/main.py +126 -0
- stcode/core/daemon/protocol.py +499 -0
- stcode/core/daemon/runner.py +359 -0
- stcode/core/daemon/server.py +682 -0
- stcode/core/daemon/workspace.py +88 -0
- stcode/core/harness/__init__.py +81 -0
- stcode/core/harness/approvals.py +85 -0
- stcode/core/harness/context.py +260 -0
- stcode/core/harness/errors.py +40 -0
- stcode/core/harness/harness.py +417 -0
- stcode/core/harness/mcp.py +530 -0
- stcode/core/harness/outputs.py +99 -0
- stcode/core/harness/prompts/__init__.py +138 -0
- stcode/core/harness/prompts/execute.py +51 -0
- stcode/core/harness/prompts/plan.py +57 -0
- stcode/core/harness/prompts/sections.py +238 -0
- stcode/core/harness/skills/__init__.py +30 -0
- stcode/core/harness/skills/loader.py +247 -0
- stcode/core/harness/tools/__init__.py +111 -0
- stcode/core/harness/tools/base.py +640 -0
- stcode/core/harness/tools/files.py +276 -0
- stcode/core/harness/tools/interact.py +72 -0
- stcode/core/harness/tools/registry.py +113 -0
- stcode/core/harness/tools/repl.py +97 -0
- stcode/core/harness/tools/schema.py +186 -0
- stcode/core/harness/tools/search.py +356 -0
- stcode/core/harness/tools/shell.py +372 -0
- stcode/core/harness/tools/skill.py +66 -0
- stcode/core/harness/tools/todo.py +62 -0
- stcode/core/harness/tools/web.py +163 -0
- stcode/core/providers/__init__.py +31 -0
- stcode/core/providers/anthropic_claude.py +243 -0
- stcode/core/providers/base.py +73 -0
- stcode/core/providers/gateway.py +267 -0
- stcode/core/providers/google_gemini.py +225 -0
- stcode/core/providers/openai_gpt.py +250 -0
- stcode/core/providers/registry.py +46 -0
- stcode/core/providers/types.py +148 -0
- stcode/core/repl/__init__.py +19 -0
- stcode/core/repl/_worker.py +179 -0
- stcode/core/repl/client.py +374 -0
- stcode/core/session/__init__.py +39 -0
- stcode/core/session/session.py +493 -0
- stcode/core/team/__init__.py +38 -0
- stcode/core/team/mailbox.py +249 -0
- stcode/core/team/tools.py +145 -0
- stcode-0.0.1a0.dist-info/METADATA +229 -0
- stcode-0.0.1a0.dist-info/RECORD +111 -0
- stcode-0.0.1a0.dist-info/WHEEL +4 -0
- stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
- 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
|
+
]
|