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,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