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,359 @@
1
+ """
2
+ SessionRunner — one live agent, zero or more watchers.
3
+
4
+ Where the daemon's three hard points actually live:
5
+
6
+ **Detach does not kill the agent.** The runner, not the connection, is the single
7
+ consumer of `agent.events()`. Clients are sinks it fans out to; losing the last one
8
+ changes nothing about the turn in flight, and events keep landing in the session for a
9
+ re-attaching client to replay.
10
+
11
+ **Approval is request–response.** `on_approval` is `async (ApprovalRequest) -> bool`,
12
+ which over a socket becomes: broadcast `approval_request`, park a `Future` under the
13
+ `execution_id`, resolve it when a client answers. The harness needed no change —
14
+ `ApprovalRequest` already carried the id. Same mechanism for `ask_user_question`, with
15
+ the id minted here because `Question` has no field for one.
16
+
17
+ **A push mid-turn queues.** That is `Agent.push()`'s promise; the runner just hands the
18
+ text over.
19
+
20
+ The failure mode worth naming: a turn blocked on approval whose last client drops would
21
+ wait forever on a `Future` nobody can resolve. So losing the last watcher fails every
22
+ pending request with `ToolDenied`, and the model reads why and moves on.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import contextlib
29
+ import uuid
30
+ from contextlib import aclosing
31
+ from typing import Any
32
+
33
+ from stcode.core.agent import Agent
34
+ from stcode.core.daemon.protocol import (
35
+ ApprovalRequested,
36
+ ErrorMessage,
37
+ InfoReply,
38
+ Progress,
39
+ QuestionAsked,
40
+ SessionOpened,
41
+ event_frame,
42
+ )
43
+ from stcode.core.configs import ApprovalMode
44
+ from stcode.core.harness.errors import ToolDenied
45
+ from stcode.core.harness.tools.base import ApprovalRequest, Question
46
+
47
+ NO_CLIENT = (
48
+ "No client is attached to this session, so there is nobody to {verb}. Continue with "
49
+ "what you can decide yourself and state the assumption you made."
50
+ )
51
+
52
+ WAKE = (
53
+ "A message arrived while you were idle. It is above this line. Deal with it, or say "
54
+ "why it is not yours to deal with."
55
+ )
56
+ """What an inbox-woken turn is pushed. Short on purpose — the message is already in
57
+ the history as an `inbox` record, and repeating it pays for it twice."""
58
+
59
+
60
+ USAGE_FIELDS = (
61
+ "input_tokens",
62
+ "output_tokens",
63
+ "cache_creation_input_tokens",
64
+ "cache_read_input_tokens",
65
+ )
66
+
67
+
68
+ def session_usage(records: list[dict[str, Any]]) -> dict[str, int]:
69
+ """Add up every `usage` record in a transcript, plus how many there were.
70
+
71
+ Summed **here**, from the session, rather than accumulated by the client from the
72
+ frames it happened to see. Two reasons, and both of them are correctness rather than
73
+ tidiness: `turn_finished` carries the usage of the last model call in the turn, not
74
+ of the turn — a turn with six tool calls made seven requests and reports one — and a
75
+ client that attached halfway through never saw the earlier ones at all. The session
76
+ has one record per call and is the only thing that does.
77
+
78
+ A sub-agent's calls are not in here: those are records in the sub-agent's own
79
+ session file, which is the same reason its tool output never enters the parent's
80
+ context.
81
+ """
82
+ totals = {field: 0 for field in USAGE_FIELDS}
83
+ calls = 0
84
+ for record in records:
85
+ if record.get("type") != "usage":
86
+ continue
87
+ calls += 1
88
+ for field in USAGE_FIELDS:
89
+ totals[field] += int(record.get(field, 0) or 0)
90
+ totals["calls"] = calls
91
+ return totals
92
+
93
+
94
+ class SessionRunner:
95
+ """One session the daemon is holding: its agent, its watchers, its open requests."""
96
+
97
+ def __init__(self, agent: Agent) -> None:
98
+ self.agent = agent
99
+ self.id = agent.session.id
100
+ self._sinks: set["asyncio.Queue[dict[str, Any]]"] = set()
101
+ # execution_id -> (future the tool is parked on, the frame that asked). The
102
+ # frame is kept so a client attaching mid-question sees what is being waited
103
+ # for, rather than a session that looks hung.
104
+ self._pending: dict[str, tuple["asyncio.Future[Any]", dict[str, Any]]] = {}
105
+ self._task: asyncio.Task[None] | None = None
106
+ self._watch: asyncio.Task[None] | None = None
107
+
108
+ # The agent asks; this runner answers over the wire. Wired here rather than in
109
+ # `Harness.create`, because the harness must not know a socket exists.
110
+ agent.attach(
111
+ on_approval=self.request_approval,
112
+ on_ask=self.ask,
113
+ on_progress=self.progress,
114
+ on_event=self.emit_child,
115
+ )
116
+
117
+ # ---- lifecycle ----
118
+
119
+ def start(self) -> None:
120
+ """Begin draining `agent.events()`. Idempotent — attach may call it again."""
121
+ if self._task is None or self._task.done():
122
+ self._task = asyncio.create_task(self._pump(), name=f"stcode-session-{self.id}")
123
+
124
+ async def _pump(self) -> None:
125
+ """The single consumer of the agent's event stream.
126
+
127
+ `events()` is one-consumer — two iterators would race for the same inbox — so
128
+ the fan-out happens on this side of it.
129
+ """
130
+ try:
131
+ async with aclosing(self.agent.events()) as stream:
132
+ async for event in stream:
133
+ self.broadcast(event_frame(self.id, event))
134
+ except asyncio.CancelledError:
135
+ raise
136
+ except Exception as exc: # noqa: BLE001 — a dead pump must be visible, not silent
137
+ self.broadcast(
138
+ ErrorMessage(session=self.id, message=f"{type(exc).__name__}: {exc}").model_dump()
139
+ )
140
+
141
+ def watch_inbox(self, mailbox: Any, interval: float = 1.0) -> None:
142
+ """Start a turn when a message lands and the agent is idle.
143
+
144
+ Polling, not inotify: one `listdir` a second costs nothing measurable, behaves
145
+ the same on every filesystem a volume might be, and needs no dependency.
146
+
147
+ Only when **idle**. A message arriving mid-turn is already delivered — the agent
148
+ drains its inbox at the top of the next turn anyway — and interrupting would
149
+ break the rule that a push never splices into a turn in flight.
150
+ """
151
+ if self._watch is not None and not self._watch.done():
152
+ return
153
+ self._watch = asyncio.create_task(
154
+ self._poll_inbox(mailbox, interval), name=f"stcode-inbox-{self.id}"
155
+ )
156
+
157
+ async def _poll_inbox(self, mailbox: Any, interval: float) -> None:
158
+ while True:
159
+ await asyncio.sleep(interval)
160
+ try:
161
+ if self.agent.busy or not mailbox.pending():
162
+ continue
163
+ await self.push(WAKE)
164
+ except asyncio.CancelledError:
165
+ raise
166
+ except Exception as exc: # noqa: BLE001 — a broken volume must not be silent
167
+ self.broadcast(
168
+ ErrorMessage(session=self.id, message=f"inbox watch: {exc}").model_dump()
169
+ )
170
+ return
171
+
172
+ async def aclose(self) -> None:
173
+ if self._watch is not None:
174
+ self._watch.cancel()
175
+ with contextlib.suppress(asyncio.CancelledError):
176
+ await self._watch
177
+ self._watch = None
178
+ if self._task is not None:
179
+ self._task.cancel()
180
+ with contextlib.suppress(asyncio.CancelledError):
181
+ await self._task
182
+ self._task = None
183
+ self._fail_pending("the session is shutting down")
184
+ await self.agent.aclose()
185
+
186
+ # ---- watchers ----
187
+
188
+ def subscribe(self, sink: "asyncio.Queue[dict[str, Any]]") -> None:
189
+ self._sinks.add(sink)
190
+
191
+ def unsubscribe(self, sink: "asyncio.Queue[dict[str, Any]]") -> None:
192
+ self._sinks.discard(sink)
193
+ if not self._sinks:
194
+ self._fail_pending("the last client detached")
195
+
196
+ @property
197
+ def watchers(self) -> int:
198
+ return len(self._sinks)
199
+
200
+ def broadcast(self, frame: dict[str, Any]) -> None:
201
+ """Fan one frame out to every watcher.
202
+
203
+ Unbounded queues and `put_nowait`: broadcasting must never block the agent loop
204
+ on a slow client, and dropping a `tool_finished` to save memory would leave that
205
+ client's transcript permanently wrong.
206
+ """
207
+ for sink in list(self._sinks):
208
+ sink.put_nowait(frame)
209
+
210
+ def describe(self) -> SessionOpened:
211
+ """What this session *is* — meta with any overrides folded on top.
212
+
213
+ The effective settings, not the ones it started with: after a `/model` the two
214
+ differ, and a status line showing the older of them is a status line that lies.
215
+ """
216
+ session = self.agent.session
217
+ settings = {**session.meta(), **session.overrides()}
218
+ return SessionOpened(
219
+ id=self.id,
220
+ cwd=str(settings.get("cwd", "")),
221
+ role=str(settings.get("role", "")),
222
+ model=str(settings.get("model", "")),
223
+ provider=str(settings.get("provider", "")),
224
+ reasoning_effort=str(settings.get("reasoning_effort", "")),
225
+ approval_mode=self.agent.harness.approval_mode,
226
+ busy=self.agent.busy,
227
+ started=session.started,
228
+ )
229
+
230
+ def inform(self, **extra: Any) -> InfoReply:
231
+ """What this machine has — skills, MCP servers, tools, paths.
232
+
233
+ Assembled here because every field is the *agent's*, and a client cannot see
234
+ any of it once the agent is in a container. `extra` is what only the daemon
235
+ knows: its own address, and which config file it was started from.
236
+ """
237
+ harness = self.agent.harness
238
+ skills = harness.context.skills
239
+ return InfoReply(
240
+ session=self.id,
241
+ cwd=str(harness.context.cwd),
242
+ role=str(self.agent.session.meta().get("role", "")),
243
+ session_path=str(self.agent.session.path),
244
+ approval_mode=harness.approval_mode,
245
+ tools=harness.tool_names(),
246
+ skills=[
247
+ {"name": skill.name, "description": skill.description, "source": skill.source}
248
+ for skill in (skills or [])
249
+ ],
250
+ mcp=[
251
+ {"name": server, "tools": tools}
252
+ for server, tools in sorted(harness.mcp_servers.items())
253
+ ],
254
+ usage=session_usage(self.agent.session.records()),
255
+ **extra,
256
+ )
257
+
258
+ def open_requests(self) -> list[dict[str, Any]]:
259
+ """Requests the agent is currently parked on, for a client that just attached."""
260
+ return [frame for _future, frame in self._pending.values()]
261
+
262
+ # ---- driving ----
263
+
264
+ async def push(self, text: str) -> None:
265
+ self.start()
266
+ await self.agent.push(text)
267
+
268
+ async def interrupt(self) -> None:
269
+ await self.agent.interrupt()
270
+
271
+ def set_meta(self, fields: dict[str, Any]) -> None:
272
+ """Override this session's model, provider or reasoning effort.
273
+
274
+ Straight through to the session: `Agent._overrides()` reads it back before the
275
+ next model call, so this reaches the live turn the same way `set_mode` does —
276
+ at the next request, never inside the one in flight.
277
+ """
278
+ if fields:
279
+ self.agent.session.set_meta(**fields)
280
+
281
+ def set_mode(self, mode: ApprovalMode) -> None:
282
+ """Change the approval mode of the live agent.
283
+
284
+ The harness holds the mode and `tool_definitions()` is read fresh each request,
285
+ so this takes effect on the next model call. It does not reach a tool already in
286
+ flight — that call was gated under the mode in force when it started.
287
+ """
288
+ self.agent.harness.approval_mode = mode
289
+
290
+ # ---- request/response over the wire ----
291
+
292
+ async def request_approval(self, request: ApprovalRequest) -> bool:
293
+ answer = await self._await_client(
294
+ request.execution_id,
295
+ ApprovalRequested.of(self.id, request),
296
+ verb=f"approve `{request.tool_name}`",
297
+ )
298
+ return bool(answer)
299
+
300
+ async def ask(self, question: Question) -> str:
301
+ execution_id = uuid.uuid4().hex[:12]
302
+ answer = await self._await_client(
303
+ execution_id,
304
+ QuestionAsked.of(self.id, execution_id, question),
305
+ verb="answer a question",
306
+ )
307
+ return str(answer)
308
+
309
+ async def progress(self, text: str) -> None:
310
+ self.broadcast(Progress(session=self.id, text=text).model_dump())
311
+
312
+ def emit_child(self, agent_name: str, event: Any) -> None:
313
+ """Fan out one sub-agent event, named.
314
+
315
+ The same frame a parent's event produces plus an `agent` field — and nothing is
316
+ recorded, because the sub-agent's own session already has it. Synchronous: this
317
+ is the tail of `Runtime.emit`, and a sub-agent must not wait on a client.
318
+ """
319
+ self.broadcast(event_frame(self.id, event, agent=agent_name))
320
+
321
+ async def _await_client(self, execution_id: str, message: Any, *, verb: str) -> Any:
322
+ """Ask the attached clients something and block this tool until one answers.
323
+
324
+ `ToolDenied` for "nobody is attached" rather than False: both end the call, but
325
+ `Tool.invoke`'s generic denial text says *the user declined*, and a headless run
326
+ told a human refused it will act on that lie.
327
+ """
328
+ if not self._sinks:
329
+ raise ToolDenied(NO_CLIENT.format(verb=verb))
330
+
331
+ future: "asyncio.Future[Any]" = asyncio.get_running_loop().create_future()
332
+ frame = message.model_dump(mode="json")
333
+ self._pending[execution_id] = (future, frame)
334
+ self.broadcast(frame)
335
+ try:
336
+ return await future
337
+ finally:
338
+ self._pending.pop(execution_id, None)
339
+
340
+ def resolve(self, execution_id: str, value: Any) -> bool:
341
+ """Answer one open request. False when the id matches nothing still waiting: a
342
+ late answer to an abandoned request is not an error."""
343
+ entry = self._pending.get(execution_id)
344
+ if entry is None or entry[0].done():
345
+ return False
346
+ entry[0].set_result(value)
347
+ return True
348
+
349
+ def _fail_pending(self, reason: str) -> None:
350
+ for future, _frame in list(self._pending.values()):
351
+ if not future.done():
352
+ future.set_exception(ToolDenied(f"The request was abandoned: {reason}."))
353
+ self._pending.clear()
354
+
355
+ def __repr__(self) -> str:
356
+ return f"SessionRunner({self.id}, watchers={self.watchers}, busy={self.agent.busy})"
357
+
358
+
359
+ __all__ = ["NO_CLIENT", "USAGE_FIELDS", "SessionRunner", "session_usage"]