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