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,682 @@
1
+ """
2
+ Daemon — one process, **many sessions**, addressed by id.
3
+
4
+ daemon = Daemon(config)
5
+ await daemon.start() # bind, then get on with your life
6
+ await daemon.serve_forever() # or block until `stop()` / a `shutdown` message
7
+
8
+ async with Daemon(config) as daemon: ... # start + close
9
+
10
+ Solo mode runs one daemon for every project — one session per repo, not one process per
11
+ repo. A container usually holds one, but nothing forbids more.
12
+
13
+ It owns a socket, the session registry, the connections, and one shared `LLMGateway`.
14
+ Shared because the gateway caches provider clients by `(provider, key, base_url)`, and
15
+ a daemon holding eight sessions should hold one connection pool. No agent closes it —
16
+ the daemon does, once, at shutdown.
17
+
18
+ Approvals and questions are not here at all: they belong to a session, not a
19
+ connection. The client that asked may be gone by the time the answer arrives, and a
20
+ second client on the same session can answer instead.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import contextlib
27
+ import logging
28
+ import os
29
+ import socket as socketlib
30
+ from pathlib import Path
31
+ from types import TracebackType
32
+ from typing import Any
33
+
34
+ from stcode import __version__
35
+ from stcode.core.agent import Agent
36
+ from stcode.core.common import trace
37
+ from stcode.core.configs import (
38
+ PROVIDER_KEY_ENV,
39
+ PROVIDER_NAMES,
40
+ ApprovalMode,
41
+ Config,
42
+ load_config,
43
+ update_config,
44
+ )
45
+ from stcode.core.daemon.autonomy import AutonomyRefused, guard_autonomy, in_container
46
+ from stcode.core.daemon.protocol import (
47
+ STREAM_LIMIT,
48
+ Answer,
49
+ Approval,
50
+ Attach,
51
+ ClientMessage,
52
+ ConfigReply,
53
+ Create,
54
+ Detach,
55
+ ErrorMessage,
56
+ GetConfig,
57
+ WorkspaceFiles,
58
+ WorkspaceShell,
59
+ History,
60
+ Info,
61
+ Interrupt,
62
+ ProtocolError,
63
+ Push,
64
+ Sessions,
65
+ SessionList,
66
+ SetConfig,
67
+ SetMeta,
68
+ SetMode,
69
+ Shutdown,
70
+ decode,
71
+ encode,
72
+ parse_client_message,
73
+ )
74
+ from stcode.core.daemon.runner import SessionRunner
75
+ from stcode.core.daemon.workspace import list_files, run_shell
76
+ from stcode.core.providers.gateway import LLMGateway
77
+ from stcode.core.providers.registry import default_model_for
78
+ from stcode.core.session import Session
79
+
80
+ log = logging.getLogger("stcode.daemon")
81
+
82
+ SHUTDOWN_TIMEOUT = 5.0
83
+ """How long shutdown waits for the listening socket to drain. Bounded, because a
84
+ shutdown that can hang is one you learn to `kill -9` instead of trusting."""
85
+
86
+ CLIENT_LIMIT_REFUSED = "this daemon already has a client attached ({count}/{limit})"
87
+ """What the *newcomer* is told. The incumbent is never dropped to make room: a
88
+ connection that is already attached may be parked on an approval, and evicting it turns
89
+ a stray `stcode --daemonless` into a way to strand somebody else's turn."""
90
+
91
+
92
+ class Daemon:
93
+ """A socket, a session registry, and the connections watching them."""
94
+
95
+ def __init__(
96
+ self,
97
+ config: Config,
98
+ *,
99
+ has_approver: bool = False,
100
+ gateway: LLMGateway | None = None,
101
+ overrides: dict[str, Any] | None = None,
102
+ ) -> None:
103
+ """`has_approver` is False for everything stcode ships — `autonomy.py` says why
104
+ an attached human is not one under `full-auto`. `gateway` is for tests and for
105
+ embedding; left None, the daemon builds and owns one.
106
+
107
+ `overrides` are the dotted keys `config` was loaded with (command-line flags),
108
+ reapplied whenever `set_config` reloads the file. `config.path` is the file
109
+ `set_config` writes; a config built in memory has none and cannot be changed.
110
+ """
111
+ self.config = config
112
+ self.overrides = dict(overrides or {})
113
+ self.has_approver = has_approver
114
+ self.sessions: dict[str, SessionRunner] = {}
115
+ self._connections: set["_Connection"] = set()
116
+ self._gateway = gateway
117
+ self._owns_gateway = gateway is None
118
+ self._server: asyncio.base_events.Server | None = None
119
+ self._stopped = asyncio.Event()
120
+
121
+ # ---- lifecycle ----
122
+
123
+ @property
124
+ def address(self) -> str:
125
+ """Where clients should look. Also what `--headless` prints."""
126
+ return self.config.daemon.address
127
+
128
+ @property
129
+ def local_only(self) -> bool:
130
+ """Whether every client is on this machine as this user — a unix socket is
131
+ chmod 0600. Keys and `shutdown` are accepted only then."""
132
+ return self.config.daemon.transport == "unix"
133
+
134
+ @property
135
+ def max_clients(self) -> int:
136
+ """Connections allowed at once, resolved. `0` is unlimited.
137
+
138
+ Unset means **1 in a container, unlimited on the host**. The asymmetry is the
139
+ point: two terminals watching one session on your laptop is what the fan-out is
140
+ for, while a container is one agent, one checkout, one merge boundary (rule 2),
141
+ and a second terminal steering that role crosses it by accident.
142
+ """
143
+ configured = self.config.daemon.max_clients
144
+ if configured is None:
145
+ return 1 if in_container() else 0
146
+ return max(0, configured)
147
+
148
+ async def start(self) -> None:
149
+ """Bind and begin accepting. Returns once the socket is listening.
150
+
151
+ The guard runs *before* the bind: refusing after clients can connect makes the
152
+ refusal a message rather than a refusal.
153
+ """
154
+ guard_autonomy(self.config.agent.approval_mode, self.has_approver)
155
+
156
+ settings = self.config.daemon
157
+ if settings.transport == "unix":
158
+ path = socket_path(settings.socket)
159
+ path.parent.mkdir(parents=True, exist_ok=True)
160
+ _clear_stale_socket(path)
161
+ self._server = await asyncio.start_unix_server(
162
+ self._handle, path=str(path), limit=STREAM_LIMIT
163
+ )
164
+ # The socket is the access control here: anyone who can open it can drive
165
+ # an agent with your privileges.
166
+ with contextlib.suppress(OSError):
167
+ path.chmod(0o600)
168
+ else:
169
+ self._server = await asyncio.start_server(
170
+ self._handle, settings.host, settings.port, limit=STREAM_LIMIT
171
+ )
172
+ log.info("listening on %s", self.address)
173
+
174
+ async def serve_forever(self) -> None:
175
+ """Serve until `stop()`. The caller still owns `aclose()`."""
176
+ if self._server is None:
177
+ await self.start()
178
+ await self._stopped.wait()
179
+
180
+ def stop(self) -> None:
181
+ """End `serve_forever`. Safe from a signal handler."""
182
+ self._stopped.set()
183
+
184
+ async def aclose(self) -> None:
185
+ """Close the door, then everything behind it.
186
+
187
+ Order is load-bearing and not the obvious one: `Server.wait_closed()` waits for
188
+ every *handler*, not just the listening socket, so closing the server with a
189
+ client still attached waits forever on a connection that is waiting for us.
190
+ Clients, then the server, then the sessions, then the gateway.
191
+ """
192
+ for connection in list(self._connections):
193
+ await connection.aclose()
194
+ self._connections.clear()
195
+ if self._server is not None:
196
+ self._server.close()
197
+ with contextlib.suppress(Exception):
198
+ await asyncio.wait_for(self._server.wait_closed(), SHUTDOWN_TIMEOUT)
199
+ self._server = None
200
+ for runner in list(self.sessions.values()):
201
+ await runner.aclose()
202
+ self.sessions.clear()
203
+ if self._gateway is not None and self._owns_gateway:
204
+ await self._gateway.aclose()
205
+ self._gateway = None
206
+ if self.local_only:
207
+ with contextlib.suppress(OSError):
208
+ socket_path(self.config.daemon.socket).unlink()
209
+ # Last, and only here: spans are batched, so without a flush the final turn of
210
+ # a short run — the one you were watching — dies in a buffer. A no-op when
211
+ # tracing was never switched on.
212
+ await asyncio.to_thread(trace.shutdown)
213
+
214
+ async def __aenter__(self) -> "Daemon":
215
+ await self.start()
216
+ return self
217
+
218
+ async def __aexit__(
219
+ self,
220
+ exc_type: type[BaseException] | None,
221
+ exc: BaseException | None,
222
+ tb: TracebackType | None,
223
+ ) -> None:
224
+ await self.aclose()
225
+
226
+ # ---- sessions ----
227
+
228
+ def gateway(self) -> LLMGateway:
229
+ if self._gateway is None:
230
+ self._gateway = LLMGateway(self.config.model)
231
+ return self._gateway
232
+
233
+ def config_frame(self) -> ConfigReply:
234
+ """This daemon's config as a client may see it: no keys, only whether one resolves."""
235
+ model = self.config.model
236
+ default = model.default_name()
237
+ entry = model.providers.get(default)
238
+ path = self.config.path
239
+ return ConfigReply(
240
+ path=str(path or ""),
241
+ writable=path is not None and _writable(path),
242
+ keys_editable=self.local_only,
243
+ provider=default,
244
+ model=(entry.model or default_model_for(entry.provider)) if entry else "",
245
+ reasoning_effort=self.config.agent.reasoning_effort,
246
+ approval_mode=self.config.agent.approval_mode,
247
+ providers={
248
+ name: {"provider": item.provider, "model": item.model, "has_key": bool(item.key())}
249
+ for name, item in model.providers.items()
250
+ },
251
+ libraries={
252
+ name: {"default_model": default_model_for(name), "key_env": PROVIDER_KEY_ENV[name]}
253
+ for name in PROVIDER_NAMES
254
+ },
255
+ )
256
+
257
+ async def set_config(self, message: SetConfig) -> None:
258
+ """Write a `set_config` patch to the file, reload it, and reconfigure the gateway.
259
+
260
+ The gateway is reconfigured in place, so every live session picks up a new key
261
+ or default model on its next call. `[daemon]` is not re-read: the socket is
262
+ already bound.
263
+ """
264
+ if self.config.path is None:
265
+ raise OSError("this daemon's config was not read from a file")
266
+ changes: dict[str, Any] = {}
267
+ if message.approval_mode:
268
+ # `set_mode` is not the only door a mode arrives through. The guard goes on
269
+ # every door, and a default the daemon would refuse to start with is one.
270
+ guard_autonomy(message.approval_mode, self.has_approver)
271
+ changes["agent.approval_mode"] = message.approval_mode
272
+ if message.reasoning_effort:
273
+ changes["agent.reasoning_effort"] = message.reasoning_effort
274
+
275
+ providers = self.config.model.providers
276
+ name = message.provider or self.config.model.default_name()
277
+ if message.provider:
278
+ if name not in providers:
279
+ if name not in PROVIDER_NAMES:
280
+ raise ValueError(f"unknown provider {name!r}")
281
+ changes[f"model.providers.{name}.provider"] = name
282
+ changes["model.default"] = name
283
+ if message.model is not None and name:
284
+ changes[f"model.providers.{name}.model"] = message.model.strip() or None
285
+ if message.api_key and name:
286
+ if not self.local_only:
287
+ raise PermissionError("keys can only be changed over the daemon's unix socket")
288
+ changes[f"model.providers.{name}.api_key"] = message.api_key.strip()
289
+
290
+ if not changes:
291
+ return
292
+ update_config(self.config.path, changes)
293
+ await self.reload()
294
+ log.info("config updated from a client: %s", ", ".join(sorted(changes)))
295
+
296
+ async def reload(self) -> None:
297
+ """Re-read the config file, keeping the bound `[daemon]` and this run's flags."""
298
+ assert self.config.path is not None
299
+ fresh = load_config(self.config.path, self.config.cwd, overrides=self.overrides)
300
+ fresh.daemon = self.config.daemon
301
+ self.config = fresh
302
+ if self._gateway is not None:
303
+ await self._gateway.reconfigure(fresh.model)
304
+ for runner in self.sessions.values():
305
+ runner.agent.reasoning_effort = fresh.agent.reasoning_effort
306
+
307
+ async def create_session(
308
+ self,
309
+ *,
310
+ cwd: str | Path | None = None,
311
+ role: str = "",
312
+ approval_mode: ApprovalMode | None = None,
313
+ session: Session | None = None,
314
+ ) -> SessionRunner:
315
+ """Build an agent and register it. The guard applies per session as well as per
316
+ daemon — a `full-auto` session on a `suggest` daemon is the same hole."""
317
+ mode = approval_mode or self.config.agent.approval_mode
318
+ guard_autonomy(mode, self.has_approver)
319
+
320
+ resolved_cwd = self.config.cwd
321
+ if cwd is not None:
322
+ p = Path(cwd).expanduser()
323
+ if p.is_dir():
324
+ resolved_cwd = p
325
+ else:
326
+ log.warning("requested cwd %s does not exist; falling back to daemon cwd", cwd)
327
+
328
+ agent = await Agent.create(
329
+ self.config,
330
+ cwd=resolved_cwd,
331
+ role=role,
332
+ approval_mode=mode,
333
+ session=session,
334
+ gateway=self.gateway(),
335
+ )
336
+ runner = SessionRunner(agent)
337
+ runner.start()
338
+ # Team mode: a role container reacts to a colleague rather than waiting to be
339
+ # poked by a human who may not be attached.
340
+ if agent.mailbox is not None and self.config.team.wake_on_message:
341
+ runner.watch_inbox(agent.mailbox, self.config.team.poll_interval)
342
+ self.sessions[runner.id] = runner
343
+ log.info("session %s created at %s", runner.id, agent.harness.context.cwd)
344
+ return runner
345
+
346
+ async def resume_session(self, session_id: str) -> SessionRunner:
347
+ """Attach to a session this daemon is not holding, by reading it off disk.
348
+
349
+ `Session(path)` adopts the records already there, so the resumed agent's
350
+ `messages()` is the conversation that happened rather than an empty one
351
+ appended to an old transcript.
352
+ """
353
+ existing = self.sessions.get(session_id)
354
+ if existing is not None:
355
+ return existing
356
+ session = Session.resume(session_id, directory=self.config.session.dir)
357
+ meta = session.meta()
358
+ return await self.create_session(
359
+ cwd=meta.get("cwd") or None, role=str(meta.get("role", "")), session=session
360
+ )
361
+
362
+ async def drop_unused(self, runner: SessionRunner) -> None:
363
+ """Forget a session that never wrote anything and has nobody watching.
364
+
365
+ `/clear` is a `create`, so without this the daemon holds every session anybody
366
+ opened and walked away from, for as long as it runs.
367
+
368
+ A session that **has** written a record is never dropped, however long its last
369
+ client has been gone: detach does not kill the agent, and that rule has no
370
+ exceptions. The test is the transcript, not the clock.
371
+ """
372
+ if runner.watchers or runner.agent.session.started:
373
+ return
374
+ self.sessions.pop(runner.id, None)
375
+ await runner.aclose()
376
+ log.info("session %s dropped — never used", runner.id)
377
+
378
+ def session_summaries(self, limit: int = 20) -> list[dict[str, Any]]:
379
+ """What this daemon is holding, merged over what is on disk.
380
+
381
+ Live sessions are the same rows with `live` and `busy` set, so one call tells a
382
+ client "running right now" apart from "a transcript you could resume".
383
+ """
384
+ rows = Session.list(limit, directory=self.config.session.dir)
385
+ by_id = {str(row.get("id", "")): row for row in rows}
386
+ for runner in self.sessions.values():
387
+ row = by_id.setdefault(runner.id, {"id": runner.id})
388
+ row.update(runner.describe().model_dump(mode="json"))
389
+ row["type"] = "meta"
390
+ for row in by_id.values():
391
+ row.setdefault("live", False)
392
+ row.setdefault("busy", False)
393
+ live = [row for row in by_id.values() if row["id"] in self.sessions]
394
+ for row in live:
395
+ row["live"] = True
396
+ return sorted(by_id.values(), key=lambda row: str(row.get("id", "")), reverse=True)[:limit]
397
+
398
+ # ---- connections ----
399
+
400
+ async def _handle(
401
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
402
+ ) -> None:
403
+ limit = self.max_clients
404
+ if limit and len(self._connections) >= limit:
405
+ # Refused with a sentence, not a socket that shuts without a word: the
406
+ # client has to have something to print. → `CLIENT_LIMIT_REFUSED`
407
+ message = CLIENT_LIMIT_REFUSED.format(count=len(self._connections), limit=limit)
408
+ log.warning("refused %s — %s", _peer(writer), message)
409
+ with contextlib.suppress(ConnectionError, OSError):
410
+ writer.write(encode(ErrorMessage(message=message)))
411
+ await writer.drain()
412
+ writer.close()
413
+ await writer.wait_closed()
414
+ return
415
+
416
+ connection = _Connection(self, reader, writer)
417
+ self._connections.add(connection)
418
+ peer = _peer(writer)
419
+ log.info("client connected: %s (%d/%s)", peer, len(self._connections), limit or "∞")
420
+ try:
421
+ await connection.run()
422
+ finally:
423
+ self._connections.discard(connection)
424
+ await connection.aclose()
425
+ log.info("client disconnected: %s", peer)
426
+
427
+
428
+ class _Connection:
429
+ """One client. Reads lines, writes frames, remembers which session it is on.
430
+
431
+ Two tasks: the read loop dispatches, and a writer drains one outbound queue. The
432
+ runner broadcasts into that queue rather than to a socket, so a client that stopped
433
+ reading slows nothing down but itself.
434
+ """
435
+
436
+ def __init__(
437
+ self, daemon: Daemon, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
438
+ ) -> None:
439
+ self._daemon = daemon
440
+ self._reader = reader
441
+ self._writer = writer
442
+ self._outbox: "asyncio.Queue[dict[str, Any]]" = asyncio.Queue()
443
+ self._drain_task = asyncio.create_task(self._drain(), name="stcode-conn-writer")
444
+ self._attached: set[str] = set()
445
+ self._current: str = ""
446
+ self._workspace_tasks: set[asyncio.Task[None]] = set()
447
+
448
+ # ---- io ----
449
+
450
+ async def _drain(self) -> None:
451
+ while True:
452
+ frame = await self._outbox.get()
453
+ try:
454
+ self._writer.write(encode(frame))
455
+ await self._writer.drain()
456
+ except (ConnectionError, OSError):
457
+ return # The client is gone; the read loop is about to notice too.
458
+
459
+ def send(self, message: Any) -> None:
460
+ self._outbox.put_nowait(
461
+ message if isinstance(message, dict) else message.model_dump(mode="json")
462
+ )
463
+
464
+ async def run(self) -> None:
465
+ # StreamReader iterates by line, which is exactly the framing — no buffer of
466
+ # our own to get wrong. The cap is `STREAM_LIMIT` rather than asyncio's 64 KiB
467
+ # default: a pasted file in a `push` is an ordinary message, not a client we
468
+ # cannot talk to.
469
+ async for line in self._reader:
470
+ if not line.strip():
471
+ continue
472
+ try:
473
+ message = parse_client_message(decode(line))
474
+ except ProtocolError as exc:
475
+ self.send(ErrorMessage(message=str(exc)))
476
+ continue
477
+ try:
478
+ await self._dispatch(message)
479
+ except Exception as exc: # noqa: BLE001 — one bad request must not drop the client
480
+ log.exception("dispatch failed")
481
+ self.send(ErrorMessage(message=f"{type(exc).__name__}: {exc}"))
482
+
483
+ async def aclose(self) -> None:
484
+ """Detach from everything, then close the socket. **Never touches the agents** —
485
+ that is the whole promise of the daemon."""
486
+ for task in self._workspace_tasks:
487
+ task.cancel()
488
+ await asyncio.gather(*self._workspace_tasks, return_exceptions=True)
489
+ for session_id in list(self._attached):
490
+ await self._detach(session_id)
491
+ self._drain_task.cancel()
492
+ with contextlib.suppress(asyncio.CancelledError):
493
+ await self._drain_task
494
+ with contextlib.suppress(ConnectionError, OSError):
495
+ self._writer.close()
496
+ await self._writer.wait_closed()
497
+
498
+ # ---- dispatch ----
499
+
500
+ async def _dispatch(self, message: ClientMessage) -> None:
501
+ match message:
502
+ case Create():
503
+ runner = await self._daemon.create_session(
504
+ cwd=message.cwd or None,
505
+ role=message.role,
506
+ approval_mode=message.approval_mode,
507
+ )
508
+ self._attach(runner, replay=False)
509
+ case Attach():
510
+ runner = await self._daemon.resume_session(message.session)
511
+ self._attach(runner, replay=message.replay)
512
+ case Detach():
513
+ await self._detach(self._target_id(message.session))
514
+ case Sessions():
515
+ self.send(SessionList(sessions=self._daemon.session_summaries(message.limit)))
516
+ case Push():
517
+ await self._runner(message.session).push(message.text)
518
+ case Interrupt():
519
+ await self._runner(message.session).interrupt()
520
+ case Approval():
521
+ self._runner(message.session).resolve(message.execution_id, message.approved)
522
+ case Answer():
523
+ self._runner(message.session).resolve(message.execution_id, message.text)
524
+ case SetMeta():
525
+ runner = self._runner(message.session)
526
+ runner.set_meta(message.fields())
527
+ # Same discipline as `set_mode`: report what the settings now *are*,
528
+ # so a client never shows a change it only asked for.
529
+ self.send(runner.describe())
530
+ case Info():
531
+ runner = self._runner(message.session)
532
+ self.send(
533
+ runner.inform(
534
+ version=__version__,
535
+ daemon=self._daemon.address,
536
+ # The file this daemon actually read, not the conventional
537
+ # location — a container's is wherever STCODE_CONFIG pointed.
538
+ config_path=str(self._daemon.config.path or ""),
539
+ session_dir=str(self._daemon.config.session.dir),
540
+ )
541
+ )
542
+ case WorkspaceFiles() | WorkspaceShell():
543
+ task = asyncio.create_task(self._workspace_request(message))
544
+ self._workspace_tasks.add(task)
545
+ task.add_done_callback(self._workspace_tasks.discard)
546
+ case GetConfig():
547
+ self.send(self._daemon.config_frame())
548
+ case SetConfig():
549
+ try:
550
+ await self._daemon.set_config(message)
551
+ except (AutonomyRefused, PermissionError, ValueError, OSError) as exc:
552
+ # Rule 5 has no back door, and a config file is not one either.
553
+ self.send(ErrorMessage(message=str(exc)))
554
+ # Either way, say what the config now *is*: a client must never be left
555
+ # showing a change that was refused.
556
+ self.send(self._daemon.config_frame())
557
+ case Shutdown():
558
+ if not self._daemon.local_only:
559
+ self.send(ErrorMessage(message="shutdown is accepted over the unix socket only"))
560
+ else:
561
+ log.info("shutdown requested by a client")
562
+ self._daemon.stop()
563
+ case SetMode():
564
+ runner = self._runner(message.session)
565
+ # `create` is not the only door a mode arrives through, so the guard
566
+ # cannot live only there: cycling to `full-auto` mid-session would
567
+ # otherwise be the override flag that is not supposed to exist.
568
+ try:
569
+ guard_autonomy(message.mode, self._daemon.has_approver)
570
+ except AutonomyRefused as refusal:
571
+ self.send(ErrorMessage(session=runner.id, message=str(refusal), level="warning"))
572
+ else:
573
+ runner.set_mode(message.mode)
574
+ # Either way, report what the mode now *is*: a client must never be
575
+ # left showing a change that was refused.
576
+ self.send(runner.describe())
577
+
578
+ async def _workspace_request(self, message: WorkspaceFiles | WorkspaceShell) -> None:
579
+ reply: dict[str, Any] = {
580
+ "type": message.type, "request_id": message.request_id,
581
+ "session": self._target_id(message.session),
582
+ }
583
+ try:
584
+ runner = self._runner(message.session)
585
+ cwd = runner.agent.harness.context.cwd
586
+ if isinstance(message, WorkspaceFiles):
587
+ reply["files"] = await asyncio.to_thread(list_files, cwd)
588
+ else:
589
+ reply.update(await run_shell(message.command, cwd, message.timeout))
590
+ except Exception as exc:
591
+ reply["error"] = str(exc)
592
+ self.send(reply)
593
+
594
+ def _attach(self, runner: SessionRunner, *, replay: bool) -> None:
595
+ """Subscribe, then replay, then join the live stream.
596
+
597
+ The order is what makes re-attach lossless: subscribing first means events
598
+ arriving during the replay queue behind it instead of falling in the gap. A
599
+ record may appear twice — a duplicate is recoverable, a hole is not.
600
+ """
601
+ runner.subscribe(self._outbox)
602
+ self._attached.add(runner.id)
603
+ self._current = runner.id
604
+ self.send(runner.describe())
605
+ if replay:
606
+ self.send(History(session=runner.id, records=runner.agent.session.records()))
607
+ # Anything the agent is parked on, so a client attaching mid-turn can answer
608
+ # rather than watch a session that looks hung.
609
+ for frame in runner.open_requests():
610
+ self.send(frame)
611
+ runner.start()
612
+
613
+ async def _detach(self, session_id: str) -> None:
614
+ runner = self._daemon.sessions.get(session_id)
615
+ if runner is not None:
616
+ runner.unsubscribe(self._outbox)
617
+ self._attached.discard(session_id)
618
+ if self._current == session_id:
619
+ self._current = next(iter(self._attached), "")
620
+ if runner is not None:
621
+ await self._daemon.drop_unused(runner)
622
+
623
+ def _target_id(self, session_id: str) -> str:
624
+ return session_id or self._current
625
+
626
+ def _runner(self, session_id: str) -> SessionRunner:
627
+ target = self._target_id(session_id)
628
+ if not target:
629
+ raise ProtocolError("no session — send `create` or `attach` first")
630
+ runner = self._daemon.sessions.get(target)
631
+ if runner is None:
632
+ raise ProtocolError(f"this daemon is not holding session {target!r}")
633
+ return runner
634
+
635
+
636
+ # ---- unix socket housekeeping ---------------------------------------------------
637
+
638
+
639
+ def _peer(writer: asyncio.StreamWriter) -> str:
640
+ """Who is on the other end, for the log. A unix socket has no peer name worth
641
+ printing, so it says so rather than printing an empty tuple."""
642
+ peer = writer.get_extra_info("peername")
643
+ if isinstance(peer, tuple) and len(peer) >= 2:
644
+ return f"{peer[0]}:{peer[1]}"
645
+ return str(peer) if peer else "unix socket"
646
+
647
+
648
+ def socket_path(value: str | Path) -> Path:
649
+ return Path(value).expanduser()
650
+
651
+
652
+ def _writable(path: Path) -> bool:
653
+ """Whether `set_config` would land. A read-only mount is the common case in a
654
+ container, and a client that knows can grey the fields out instead of offering an
655
+ edit that fails."""
656
+ probe = path if path.exists() else path.parent
657
+ return os.access(probe, os.W_OK)
658
+
659
+
660
+ def _clear_stale_socket(path: Path) -> None:
661
+ """Remove a socket file left behind by a daemon that died.
662
+
663
+ A crashed daemon leaves the path in place, so `bind` fails with "address already in
664
+ use" — indistinguishable from a healthy daemon. Connecting is the only way to tell
665
+ them apart: a refused connection means nobody is home, so the file is debris.
666
+ """
667
+ if not path.exists():
668
+ return
669
+ probe = socketlib.socket(socketlib.AF_UNIX, socketlib.SOCK_STREAM)
670
+ try:
671
+ probe.settimeout(0.5)
672
+ probe.connect(str(path))
673
+ except (ConnectionRefusedError, FileNotFoundError, socketlib.timeout, OSError):
674
+ with contextlib.suppress(OSError):
675
+ path.unlink()
676
+ return
677
+ finally:
678
+ probe.close()
679
+ raise OSError(f"a daemon is already listening on {path}")
680
+
681
+
682
+ __all__ = ["Daemon", "socket_path"]