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,393 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DaemonClient — the UI's end of the daemon's JSONL socket.
|
|
3
|
+
|
|
4
|
+
async with await DaemonClient.connect(Address()) as client:
|
|
5
|
+
await client.create(cwd=Path.cwd())
|
|
6
|
+
await client.push("Add rate limiting")
|
|
7
|
+
async for frame in client.events():
|
|
8
|
+
...
|
|
9
|
+
|
|
10
|
+
Shares nothing with `stcode/core` but the wire format: messages go out as dicts and
|
|
11
|
+
come back as dicts. It renders and interprets nothing.
|
|
12
|
+
|
|
13
|
+
**Requests and events share one socket.** `create`, `attach`, `sessions`, `info` and
|
|
14
|
+
the config verbs expect an answer while deltas arrive, so one reader routes each frame
|
|
15
|
+
to a waiter parked on that frame's type, else to the event queue. Type is enough: a
|
|
16
|
+
client has at most one of those in flight, and everything else is fire-and-forget or
|
|
17
|
+
keyed by `execution_id`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import contextlib
|
|
24
|
+
import json
|
|
25
|
+
import uuid
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from types import TracebackType
|
|
29
|
+
from typing import Any, AsyncIterator
|
|
30
|
+
|
|
31
|
+
from stcode.cli.services.paths import stcode_home
|
|
32
|
+
|
|
33
|
+
STREAM_LIMIT = 64 * 1024 * 1024
|
|
34
|
+
"""Longest line either end accepts. `history` is one line carrying a whole transcript,
|
|
35
|
+
and asyncio's 64 KiB default would make a session unresumable after ~30 tool calls."""
|
|
36
|
+
|
|
37
|
+
REPLY_TIMEOUT = 30.0
|
|
38
|
+
"""How long a synchronous verb waits. Long enough for `create` to sample git and
|
|
39
|
+
connect MCP servers on a cold repo; short enough that a wedged daemon is reported
|
|
40
|
+
rather than hung on."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Address:
|
|
45
|
+
"""Where a daemon listens."""
|
|
46
|
+
|
|
47
|
+
transport: str = "unix"
|
|
48
|
+
socket: Path = field(default_factory=lambda: stcode_home() / "daemon.sock")
|
|
49
|
+
host: str = "127.0.0.1"
|
|
50
|
+
port: int = 7717
|
|
51
|
+
|
|
52
|
+
def __str__(self) -> str:
|
|
53
|
+
return (
|
|
54
|
+
str(self.socket) if self.transport == "unix" else f"{self.host}:{self.port}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def daemon_args(self) -> list[str]:
|
|
58
|
+
"""The same address as `stcode-daemon` flags."""
|
|
59
|
+
if self.transport == "unix":
|
|
60
|
+
return ["--transport", "unix", "--socket", str(self.socket)]
|
|
61
|
+
return ["--transport", "tcp", "--host", self.host, "--port", str(self.port)]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class DaemonClosed(Exception):
|
|
65
|
+
"""The daemon went away, refused a request, or did not answer it."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class RequestRefused(DaemonClosed):
|
|
69
|
+
"""A request was rejected while the daemon connection remains usable."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class DaemonClient:
|
|
73
|
+
"""One connection to one daemon."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
77
|
+
) -> None:
|
|
78
|
+
self._reader = reader
|
|
79
|
+
self._writer = writer
|
|
80
|
+
self._events: "asyncio.Queue[dict[str, Any]]" = asyncio.Queue()
|
|
81
|
+
self._waiters: dict[str, "asyncio.Future[dict[str, Any]]"] = {}
|
|
82
|
+
self._workspace_waiters: dict[str, asyncio.Future[dict[str, Any]]] = {}
|
|
83
|
+
self._closed = asyncio.Event()
|
|
84
|
+
self._read_task = asyncio.create_task(
|
|
85
|
+
self._read_loop(), name="stcode-client-reader"
|
|
86
|
+
)
|
|
87
|
+
self.session_id: str = ""
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
async def connect(cls, address: Address) -> "DaemonClient":
|
|
91
|
+
"""Open the transport. `OSError` when nothing is listening — the caller's cue to
|
|
92
|
+
start a daemon or ask for another address, not an error to hide."""
|
|
93
|
+
if address.transport == "unix":
|
|
94
|
+
reader, writer = await asyncio.open_unix_connection(
|
|
95
|
+
str(address.socket.expanduser()), limit=STREAM_LIMIT
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
reader, writer = await asyncio.open_connection(
|
|
99
|
+
address.host, address.port, limit=STREAM_LIMIT
|
|
100
|
+
)
|
|
101
|
+
return cls(reader, writer)
|
|
102
|
+
|
|
103
|
+
# ---- io ----
|
|
104
|
+
|
|
105
|
+
async def _read_loop(self) -> None:
|
|
106
|
+
try:
|
|
107
|
+
async for line in self._reader:
|
|
108
|
+
if not line.strip():
|
|
109
|
+
continue
|
|
110
|
+
try:
|
|
111
|
+
frame = json.loads(line)
|
|
112
|
+
except json.JSONDecodeError:
|
|
113
|
+
continue # A line we cannot parse is the daemon's bug, not a reason to drop.
|
|
114
|
+
if not isinstance(frame, dict):
|
|
115
|
+
continue
|
|
116
|
+
request_id = str(frame.get("request_id", ""))
|
|
117
|
+
if request_id:
|
|
118
|
+
waiter = self._workspace_waiters.pop(request_id, None)
|
|
119
|
+
if waiter is not None and not waiter.done():
|
|
120
|
+
waiter.set_result(frame)
|
|
121
|
+
continue
|
|
122
|
+
waiter = self._waiters.pop(str(frame.get("type", "")), None)
|
|
123
|
+
if waiter is not None and not waiter.done():
|
|
124
|
+
waiter.set_result(frame)
|
|
125
|
+
else:
|
|
126
|
+
self._events.put_nowait(frame)
|
|
127
|
+
except ValueError as exc:
|
|
128
|
+
# A line longer than `STREAM_LIMIT`: the framing cannot be recovered, so the
|
|
129
|
+
# connection ends — saying so.
|
|
130
|
+
self._events.put_nowait(
|
|
131
|
+
{
|
|
132
|
+
"type": "error",
|
|
133
|
+
"message": f"frame too large for the connection: {exc}",
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
finally:
|
|
137
|
+
self._closed.set()
|
|
138
|
+
for waiter in [*self._waiters.values(), *self._workspace_waiters.values()]:
|
|
139
|
+
if not waiter.done():
|
|
140
|
+
waiter.set_exception(
|
|
141
|
+
DaemonClosed("the daemon closed the connection")
|
|
142
|
+
)
|
|
143
|
+
self._waiters.clear()
|
|
144
|
+
self._workspace_waiters.clear()
|
|
145
|
+
|
|
146
|
+
async def _fire(self, kind: str, **fields: Any) -> None:
|
|
147
|
+
message = {
|
|
148
|
+
"type": kind,
|
|
149
|
+
**{key: value for key, value in fields.items() if value is not None},
|
|
150
|
+
}
|
|
151
|
+
self._writer.write(
|
|
152
|
+
(json.dumps(message, ensure_ascii=False) + "\n").encode("utf-8")
|
|
153
|
+
)
|
|
154
|
+
await self._writer.drain()
|
|
155
|
+
|
|
156
|
+
async def _request(self, kind: str, expect: str, **fields: Any) -> dict[str, Any]:
|
|
157
|
+
"""Send, then wait for the one frame type that answers it.
|
|
158
|
+
|
|
159
|
+
An `error` frame resolves the wait too — a refused request must raise rather
|
|
160
|
+
than sit until the timeout, because the refusal *is* the answer.
|
|
161
|
+
"""
|
|
162
|
+
loop = asyncio.get_running_loop()
|
|
163
|
+
reply: "asyncio.Future[dict[str, Any]]" = loop.create_future()
|
|
164
|
+
error: "asyncio.Future[dict[str, Any]]" = loop.create_future()
|
|
165
|
+
self._waiters[expect] = reply
|
|
166
|
+
self._waiters["error"] = error
|
|
167
|
+
try:
|
|
168
|
+
await self._fire(kind, **fields)
|
|
169
|
+
done, pending = await asyncio.wait(
|
|
170
|
+
{reply, error},
|
|
171
|
+
timeout=REPLY_TIMEOUT,
|
|
172
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
173
|
+
)
|
|
174
|
+
finally:
|
|
175
|
+
self._waiters.pop(expect, None)
|
|
176
|
+
self._waiters.pop("error", None)
|
|
177
|
+
# Cancel the loser: a future holding an exception nobody retrieves is a warning
|
|
178
|
+
# printed from the garbage collector.
|
|
179
|
+
for future in pending:
|
|
180
|
+
future.cancel()
|
|
181
|
+
if not done:
|
|
182
|
+
raise DaemonClosed(
|
|
183
|
+
f"the daemon did not answer `{kind}` in {REPLY_TIMEOUT:.0f}s"
|
|
184
|
+
)
|
|
185
|
+
if error in done:
|
|
186
|
+
frame = error.result() # raises DaemonClosed if the socket died first
|
|
187
|
+
raise RequestRefused(
|
|
188
|
+
str(frame.get("message", "the daemon reported an error"))
|
|
189
|
+
)
|
|
190
|
+
return reply.result()
|
|
191
|
+
|
|
192
|
+
async def next_event(self, timeout: float | None = None) -> dict[str, Any]:
|
|
193
|
+
"""The next frame, for a caller that wants one rather than a stream."""
|
|
194
|
+
return await asyncio.wait_for(self._events.get(), timeout)
|
|
195
|
+
|
|
196
|
+
def events(self) -> AsyncIterator[dict[str, Any]]:
|
|
197
|
+
"""Everything the daemon sends that was not an answer to a request.
|
|
198
|
+
|
|
199
|
+
Waits on the next frame and the socket closing at once, and cancels both in
|
|
200
|
+
`finally`: a task left parked there is finalised by the garbage collector,
|
|
201
|
+
possibly after the loop has closed.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
async def stream() -> AsyncIterator[dict[str, Any]]:
|
|
205
|
+
pending: set["asyncio.Future[Any]"] = set()
|
|
206
|
+
try:
|
|
207
|
+
while True:
|
|
208
|
+
getter = asyncio.ensure_future(self._events.get())
|
|
209
|
+
closed = asyncio.ensure_future(self._closed.wait())
|
|
210
|
+
pending = {getter, closed}
|
|
211
|
+
done, _ = await asyncio.wait(
|
|
212
|
+
pending, return_when=asyncio.FIRST_COMPLETED
|
|
213
|
+
)
|
|
214
|
+
if getter in done:
|
|
215
|
+
closed.cancel()
|
|
216
|
+
pending = set()
|
|
217
|
+
yield getter.result()
|
|
218
|
+
continue
|
|
219
|
+
# Closed: drain what already arrived, so the last frame is not lost.
|
|
220
|
+
getter.cancel()
|
|
221
|
+
pending = set()
|
|
222
|
+
while not self._events.empty():
|
|
223
|
+
yield self._events.get_nowait()
|
|
224
|
+
return
|
|
225
|
+
finally:
|
|
226
|
+
for future in pending:
|
|
227
|
+
if not future.done():
|
|
228
|
+
future.cancel()
|
|
229
|
+
|
|
230
|
+
return stream()
|
|
231
|
+
|
|
232
|
+
async def wait_closed(self, timeout: float) -> bool:
|
|
233
|
+
"""Whether the daemon closed this connection within `timeout` seconds."""
|
|
234
|
+
try:
|
|
235
|
+
await asyncio.wait_for(self._closed.wait(), timeout)
|
|
236
|
+
except asyncio.TimeoutError:
|
|
237
|
+
return False
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
# ---- the protocol ----
|
|
241
|
+
|
|
242
|
+
async def create(
|
|
243
|
+
self,
|
|
244
|
+
*,
|
|
245
|
+
cwd: str | Path | None = None,
|
|
246
|
+
role: str = "",
|
|
247
|
+
approval_mode: str | None = None,
|
|
248
|
+
) -> dict[str, Any]:
|
|
249
|
+
frame = await self._request(
|
|
250
|
+
"create",
|
|
251
|
+
"session",
|
|
252
|
+
cwd=str(cwd or ""),
|
|
253
|
+
role=role,
|
|
254
|
+
approval_mode=approval_mode,
|
|
255
|
+
)
|
|
256
|
+
self.session_id = str(frame.get("id", ""))
|
|
257
|
+
return frame
|
|
258
|
+
|
|
259
|
+
async def attach(self, session_id: str, *, replay: bool = True) -> dict[str, Any]:
|
|
260
|
+
frame = await self._request(
|
|
261
|
+
"attach", "session", session=session_id, replay=replay
|
|
262
|
+
)
|
|
263
|
+
self.session_id = str(frame.get("id", ""))
|
|
264
|
+
return frame
|
|
265
|
+
|
|
266
|
+
async def sessions(self, limit: int = 20) -> list[dict[str, Any]]:
|
|
267
|
+
frame = await self._request("sessions", "sessions", limit=limit)
|
|
268
|
+
rows = frame.get("sessions", [])
|
|
269
|
+
return list(rows) if isinstance(rows, list) else []
|
|
270
|
+
|
|
271
|
+
async def detach(self, session_id: str = "") -> None:
|
|
272
|
+
await self._fire("detach", session=session_id)
|
|
273
|
+
|
|
274
|
+
async def push(self, text: str, session_id: str = "") -> None:
|
|
275
|
+
await self._fire("push", text=text, session=session_id)
|
|
276
|
+
|
|
277
|
+
async def interrupt(self, session_id: str = "") -> None:
|
|
278
|
+
await self._fire("interrupt", session=session_id)
|
|
279
|
+
|
|
280
|
+
async def approve(
|
|
281
|
+
self, execution_id: str, approved: bool, session_id: str = ""
|
|
282
|
+
) -> None:
|
|
283
|
+
await self._fire(
|
|
284
|
+
"approval", execution_id=execution_id, approved=approved, session=session_id
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
async def answer(self, execution_id: str, text: str, session_id: str = "") -> None:
|
|
288
|
+
await self._fire(
|
|
289
|
+
"answer", execution_id=execution_id, text=text, session=session_id
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
async def set_mode(self, mode: str, session_id: str = "") -> None:
|
|
293
|
+
"""Answered by a `session` frame with the mode now in force."""
|
|
294
|
+
await self._fire("set_mode", mode=mode, session=session_id)
|
|
295
|
+
|
|
296
|
+
async def set_meta(
|
|
297
|
+
self,
|
|
298
|
+
*,
|
|
299
|
+
model: str | None = None,
|
|
300
|
+
provider: str | None = None,
|
|
301
|
+
reasoning_effort: str | None = None,
|
|
302
|
+
session_id: str = "",
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Override this session's model, provider or effort. Answered by a `session`
|
|
305
|
+
frame saying what the settings now are."""
|
|
306
|
+
await self._fire(
|
|
307
|
+
"set_meta",
|
|
308
|
+
model=model or None,
|
|
309
|
+
provider=provider or None,
|
|
310
|
+
reasoning_effort=reasoning_effort or None,
|
|
311
|
+
session=session_id,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
async def info(self, session_id: str = "") -> dict[str, Any]:
|
|
315
|
+
"""Skills, MCP servers, tools and paths — as the **daemon's** machine sees them."""
|
|
316
|
+
return await self._request("info", "info", session=session_id)
|
|
317
|
+
|
|
318
|
+
async def _workspace_request(
|
|
319
|
+
self, kind: str, *, request_timeout: float = REPLY_TIMEOUT, **fields: Any
|
|
320
|
+
) -> dict[str, Any]:
|
|
321
|
+
request_id = uuid.uuid4().hex
|
|
322
|
+
reply: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
|
|
323
|
+
self._workspace_waiters[request_id] = reply
|
|
324
|
+
try:
|
|
325
|
+
await self._fire(kind, request_id=request_id, session=self.session_id, **fields)
|
|
326
|
+
frame = await asyncio.wait_for(reply, request_timeout)
|
|
327
|
+
if frame.get("error"):
|
|
328
|
+
raise RequestRefused(str(frame["error"]))
|
|
329
|
+
return frame
|
|
330
|
+
except asyncio.TimeoutError as exc:
|
|
331
|
+
raise DaemonClosed(f"the daemon did not answer `{kind}`") from exc
|
|
332
|
+
finally:
|
|
333
|
+
self._workspace_waiters.pop(request_id, None)
|
|
334
|
+
if not reply.done():
|
|
335
|
+
reply.cancel()
|
|
336
|
+
|
|
337
|
+
async def workspace_files(self) -> list[str]:
|
|
338
|
+
frame = await self._workspace_request("workspace_files")
|
|
339
|
+
return [str(path) for path in frame.get("files", [])]
|
|
340
|
+
|
|
341
|
+
async def workspace_shell(self, command: str, timeout: float = 10) -> dict[str, Any]:
|
|
342
|
+
duration = max(1.0, min(120.0, timeout))
|
|
343
|
+
# The shell's deadline may exceed the ordinary request deadline.
|
|
344
|
+
return await self._workspace_request(
|
|
345
|
+
"workspace_shell", request_timeout=duration + REPLY_TIMEOUT,
|
|
346
|
+
command=command, timeout=duration,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
async def get_config(self) -> dict[str, Any]:
|
|
350
|
+
"""The daemon's `config` frame: default provider and model, effort, mode, the
|
|
351
|
+
configured providers (without keys) and the libraries a new one can use."""
|
|
352
|
+
return await self._request("get_config", "config")
|
|
353
|
+
|
|
354
|
+
async def set_config(self, **fields: Any) -> dict[str, Any]:
|
|
355
|
+
"""Persist `provider`, `model`, `api_key`, `reasoning_effort` or `approval_mode`
|
|
356
|
+
in the daemon's config file. Raises `DaemonClosed` with the reason if refused."""
|
|
357
|
+
return await self._request("set_config", "config", **fields)
|
|
358
|
+
|
|
359
|
+
async def shutdown(self) -> None:
|
|
360
|
+
"""Ask the daemon to stop. Honoured over a unix socket only."""
|
|
361
|
+
await self._fire("shutdown")
|
|
362
|
+
|
|
363
|
+
# ---- lifecycle ----
|
|
364
|
+
|
|
365
|
+
async def aclose(self) -> None:
|
|
366
|
+
self._read_task.cancel()
|
|
367
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
368
|
+
await self._read_task
|
|
369
|
+
with contextlib.suppress(ConnectionError, OSError):
|
|
370
|
+
self._writer.close()
|
|
371
|
+
await self._writer.wait_closed()
|
|
372
|
+
self._closed.set()
|
|
373
|
+
|
|
374
|
+
async def __aenter__(self) -> "DaemonClient":
|
|
375
|
+
return self
|
|
376
|
+
|
|
377
|
+
async def __aexit__(
|
|
378
|
+
self,
|
|
379
|
+
exc_type: type[BaseException] | None,
|
|
380
|
+
exc: BaseException | None,
|
|
381
|
+
tb: TracebackType | None,
|
|
382
|
+
) -> None:
|
|
383
|
+
await self.aclose()
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
__all__ = [
|
|
387
|
+
"REPLY_TIMEOUT",
|
|
388
|
+
"STREAM_LIMIT",
|
|
389
|
+
"Address",
|
|
390
|
+
"DaemonClient",
|
|
391
|
+
"DaemonClosed",
|
|
392
|
+
"stcode_home",
|
|
393
|
+
]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Launcher — find the daemon at an address, or start one there as a subprocess.
|
|
3
|
+
|
|
4
|
+
launcher = Launcher(address)
|
|
5
|
+
client = await launcher.connect() # reuse a listening daemon, else start one
|
|
6
|
+
...
|
|
7
|
+
await launcher.stop() # stops only a daemon this launcher started
|
|
8
|
+
|
|
9
|
+
The daemon is a separate program (`stcode-daemon`), reached only through its socket, so
|
|
10
|
+
this is the whole of what the UI knows about how one runs. Its output goes to
|
|
11
|
+
`daemon.log` beside the socket, and when it dies on start-up the tail of that log is
|
|
12
|
+
the error the user sees.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from stcode.cli.services.client import Address, DaemonClient
|
|
25
|
+
from stcode.cli.services.paths import stcode_home
|
|
26
|
+
|
|
27
|
+
START_TIMEOUT = 20.0
|
|
28
|
+
"""How long a fresh daemon gets to start listening. MCP-free start-up is well under a
|
|
29
|
+
second; the margin is for a cold Python import on a slow disk."""
|
|
30
|
+
|
|
31
|
+
STOP_TIMEOUT = 5.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DaemonFailed(Exception):
|
|
35
|
+
"""The daemon could not be started, or exited before it listened."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def daemon_command() -> list[str]:
|
|
39
|
+
"""`stcode-daemon` from this environment when installed, else the module."""
|
|
40
|
+
search = os.pathsep.join(
|
|
41
|
+
[str(Path(sys.executable).parent), os.environ.get("PATH", "")]
|
|
42
|
+
)
|
|
43
|
+
found = shutil.which("stcode-daemon", path=search)
|
|
44
|
+
return [found] if found else [sys.executable, "-m", "stcode.core"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Launcher:
|
|
48
|
+
"""Connects to the daemon at one address, starting it when nothing listens."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
address: Address,
|
|
53
|
+
*,
|
|
54
|
+
config: Path | None = None,
|
|
55
|
+
log_path: Path | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.address = address
|
|
58
|
+
self.config = config
|
|
59
|
+
self.log_path = log_path or stcode_home() / "daemon.log"
|
|
60
|
+
self.process: subprocess.Popen[bytes] | None = None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def started(self) -> bool:
|
|
64
|
+
"""Whether this launcher started the daemon it is connected to."""
|
|
65
|
+
return self.process is not None
|
|
66
|
+
|
|
67
|
+
async def connect(self, *, restart: bool = False) -> DaemonClient:
|
|
68
|
+
"""Reuse a listening daemon, or start one. `restart` stops a running one first."""
|
|
69
|
+
if restart:
|
|
70
|
+
await self.shutdown_running()
|
|
71
|
+
try:
|
|
72
|
+
return await DaemonClient.connect(self.address)
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
self._spawn()
|
|
76
|
+
return await self._wait_until_listening()
|
|
77
|
+
|
|
78
|
+
async def shutdown_running(self) -> None:
|
|
79
|
+
"""Stop whatever daemon is listening at the address, and wait until it is gone."""
|
|
80
|
+
try:
|
|
81
|
+
client = await DaemonClient.connect(self.address)
|
|
82
|
+
except OSError:
|
|
83
|
+
return
|
|
84
|
+
async with client:
|
|
85
|
+
await client.shutdown()
|
|
86
|
+
if not await client.wait_closed(STOP_TIMEOUT):
|
|
87
|
+
raise DaemonFailed(
|
|
88
|
+
f"the daemon at {self.address} did not stop (shutdown needs a unix socket)"
|
|
89
|
+
)
|
|
90
|
+
# The daemon closes clients before its listening socket. EOF alone can race
|
|
91
|
+
# with connect() and hand the caller the very daemon it asked us to replace.
|
|
92
|
+
deadline = asyncio.get_running_loop().time() + STOP_TIMEOUT
|
|
93
|
+
while True:
|
|
94
|
+
try:
|
|
95
|
+
probe = await DaemonClient.connect(self.address)
|
|
96
|
+
except OSError:
|
|
97
|
+
return
|
|
98
|
+
await probe.aclose()
|
|
99
|
+
if asyncio.get_running_loop().time() >= deadline:
|
|
100
|
+
raise DaemonFailed(
|
|
101
|
+
f"the daemon at {self.address} is still listening after shutdown"
|
|
102
|
+
)
|
|
103
|
+
await asyncio.sleep(0.05)
|
|
104
|
+
|
|
105
|
+
async def stop(self) -> None:
|
|
106
|
+
"""Stop the daemon this launcher started. A reused one is left running."""
|
|
107
|
+
process, self.process = self.process, None
|
|
108
|
+
if process is None or process.poll() is not None:
|
|
109
|
+
return
|
|
110
|
+
process.terminate()
|
|
111
|
+
try:
|
|
112
|
+
await asyncio.to_thread(process.wait, STOP_TIMEOUT)
|
|
113
|
+
except subprocess.TimeoutExpired:
|
|
114
|
+
process.kill()
|
|
115
|
+
|
|
116
|
+
def _spawn(self) -> None:
|
|
117
|
+
command = [*daemon_command(), *self.address.daemon_args()]
|
|
118
|
+
if self.config is not None:
|
|
119
|
+
command += ["--config", str(self.config)]
|
|
120
|
+
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
with self.log_path.open("wb") as log:
|
|
122
|
+
# Its own session, so a ctrl-c meant for the UI is not also delivered here.
|
|
123
|
+
self.process = subprocess.Popen(
|
|
124
|
+
command,
|
|
125
|
+
stdin=subprocess.DEVNULL,
|
|
126
|
+
stdout=log,
|
|
127
|
+
stderr=log,
|
|
128
|
+
start_new_session=True,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def _wait_until_listening(self) -> DaemonClient:
|
|
132
|
+
assert self.process is not None
|
|
133
|
+
deadline = asyncio.get_running_loop().time() + START_TIMEOUT
|
|
134
|
+
while True:
|
|
135
|
+
try:
|
|
136
|
+
return await DaemonClient.connect(self.address)
|
|
137
|
+
except OSError:
|
|
138
|
+
pass
|
|
139
|
+
if self.process.poll() is not None:
|
|
140
|
+
self.process = None
|
|
141
|
+
raise DaemonFailed(
|
|
142
|
+
self._log_tail() or "the daemon exited before it listened"
|
|
143
|
+
)
|
|
144
|
+
if asyncio.get_running_loop().time() > deadline:
|
|
145
|
+
await self.stop()
|
|
146
|
+
raise DaemonFailed(
|
|
147
|
+
f"the daemon did not listen on {self.address} in {START_TIMEOUT:.0f}s"
|
|
148
|
+
)
|
|
149
|
+
await asyncio.sleep(0.1)
|
|
150
|
+
|
|
151
|
+
def _log_tail(self, lines: int = 5) -> str:
|
|
152
|
+
try:
|
|
153
|
+
text = self.log_path.read_text(encoding="utf-8", errors="replace")
|
|
154
|
+
except OSError:
|
|
155
|
+
return ""
|
|
156
|
+
return "\n".join(text.strip().splitlines()[-lines:])
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
__all__ = ["DaemonFailed", "Launcher", "daemon_command"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Local paths shared by client I/O services."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def stcode_home() -> Path:
|
|
8
|
+
"""`~/.stcode`, or `%APPDATA%\\stcode` on Windows. Override with `STCODE_HOME`.
|
|
9
|
+
The same rule the daemon uses, so both find the same socket."""
|
|
10
|
+
env_path = os.environ.get("STCODE_HOME")
|
|
11
|
+
if env_path:
|
|
12
|
+
return Path(env_path).expanduser()
|
|
13
|
+
if os.name == "nt":
|
|
14
|
+
return (
|
|
15
|
+
Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
16
|
+
/ "stcode"
|
|
17
|
+
)
|
|
18
|
+
return Path.home() / ".stcode"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
UI preferences — `~/.stcode/ui.toml`, and nothing else reads it.
|
|
3
|
+
|
|
4
|
+
Two keys: the theme, and the folders you have trusted. Both are facts about *this
|
|
5
|
+
terminal on this machine*, which is exactly why they are not in `config.toml` — that
|
|
6
|
+
file is read by the daemon, including one inside a container, and a container has no
|
|
7
|
+
theme and trusts nothing. → `docs/decisions/0003-what-the-tui-owns.md`
|
|
8
|
+
|
|
9
|
+
Flat rather than `[ui]`-sectioned: the file *is* the UI's, so a section header would be
|
|
10
|
+
the same word twice.
|
|
11
|
+
|
|
12
|
+
It lives in the stcode home (`STCODE_HOME`, default `~/.stcode`). It is deliberately
|
|
13
|
+
**not** moved by `--config`: that flag chooses an agent configuration for one run, and
|
|
14
|
+
one run should not be able to forget which folders you trust.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import tomli_w
|
|
23
|
+
from stcode.cli.models import (
|
|
24
|
+
UiPrefs,
|
|
25
|
+
ThemePreference,
|
|
26
|
+
THEME_PREFERENCES,
|
|
27
|
+
DEFAULT_THEME,
|
|
28
|
+
DEFAULT_SHELL_TIMEOUT,
|
|
29
|
+
MAX_SHELL_TIMEOUT,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from stcode.cli.services.paths import stcode_home
|
|
33
|
+
|
|
34
|
+
if sys.version_info >= (3, 11):
|
|
35
|
+
import tomllib
|
|
36
|
+
else: # pragma: no cover - exercised by the 3.10 CI job
|
|
37
|
+
import tomli as tomllib
|
|
38
|
+
|
|
39
|
+
UI_FILENAME = "ui.toml"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def clamp_shell_timeout(value: float) -> float:
|
|
43
|
+
"""Keep a hand-edited `shell_timeout` inside the range the UI can honour."""
|
|
44
|
+
return max(1.0, min(float(value), MAX_SHELL_TIMEOUT))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def ui_path() -> Path:
|
|
48
|
+
return stcode_home() / UI_FILENAME
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_prefs(path: Path | None = None) -> UiPrefs:
|
|
52
|
+
"""Read the preferences, falling back to the defaults for anything unreadable.
|
|
53
|
+
|
|
54
|
+
**Fails closed and never raises.** A file somebody hand-edited badly must not be
|
|
55
|
+
the reason stcode will not start — and must not be the reason it runs somewhere
|
|
56
|
+
nobody approved, so a broken `trusted` list reads as an empty one rather than as a
|
|
57
|
+
permissive one.
|
|
58
|
+
"""
|
|
59
|
+
path = path or ui_path()
|
|
60
|
+
try:
|
|
61
|
+
with path.open("rb") as handle:
|
|
62
|
+
data = tomllib.load(handle)
|
|
63
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
64
|
+
return UiPrefs()
|
|
65
|
+
if not isinstance(data, dict):
|
|
66
|
+
return UiPrefs()
|
|
67
|
+
|
|
68
|
+
theme = data.get("theme")
|
|
69
|
+
trusted = data.get("trusted")
|
|
70
|
+
timeout = data.get("shell_timeout")
|
|
71
|
+
return UiPrefs(
|
|
72
|
+
# Per key, not whole-file: an unknown theme name is no reason to forget the
|
|
73
|
+
# trusted list sitting two lines below it.
|
|
74
|
+
theme=theme if theme in THEME_PREFERENCES else DEFAULT_THEME,
|
|
75
|
+
trusted=[str(entry) for entry in trusted if isinstance(entry, str)]
|
|
76
|
+
if isinstance(trusted, list)
|
|
77
|
+
else [],
|
|
78
|
+
shell_timeout=clamp_shell_timeout(timeout)
|
|
79
|
+
if isinstance(timeout, (int, float)) and not isinstance(timeout, bool)
|
|
80
|
+
else DEFAULT_SHELL_TIMEOUT,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def save_prefs(prefs: UiPrefs, path: Path | None = None) -> Path:
|
|
85
|
+
"""Write them out, atomically. No secrets in here, so no 0600 dance — but an
|
|
86
|
+
interrupted write must not leave a file that reads as "nothing is trusted"."""
|
|
87
|
+
path = path or ui_path()
|
|
88
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
90
|
+
tmp.write_text(tomli_w.dumps(prefs.model_dump(mode="json")))
|
|
91
|
+
tmp.replace(path)
|
|
92
|
+
return path
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
__all__ = [
|
|
96
|
+
"DEFAULT_SHELL_TIMEOUT",
|
|
97
|
+
"DEFAULT_THEME",
|
|
98
|
+
"MAX_SHELL_TIMEOUT",
|
|
99
|
+
"THEME_PREFERENCES",
|
|
100
|
+
"UI_FILENAME",
|
|
101
|
+
"ThemePreference",
|
|
102
|
+
"UiPrefs",
|
|
103
|
+
"clamp_shell_timeout",
|
|
104
|
+
"load_prefs",
|
|
105
|
+
"save_prefs",
|
|
106
|
+
"ui_path",
|
|
107
|
+
]
|
|
File without changes
|