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
stcode/core/configs.py ADDED
@@ -0,0 +1,490 @@
1
+ """
2
+ Config — the one place stcode's settings are located, read, resolved and written.
3
+
4
+ Every other module gets its values from a `Config` handed to it; none reads TOML or the
5
+ environment for a setting itself.
6
+
7
+ * **Read** — `load_config` parses the TOML into a validated `Config`. Environment
8
+ switches (`STCODE_TEAM`, `STCODE_SUPERVISOR`, `STCODE_ROLE`) apply to that in-memory
9
+ copy only.
10
+ * **Secrets** — a key is a literal or a `"${ENV_VAR}"` reference, resolved by `secret()`
11
+ when it is used. The file only ever holds what was written to it.
12
+ * **Write** — `update_config` patches named keys in the TOML document. Comments and
13
+ untouched keys survive, and a patch that would not load is refused before it lands.
14
+
15
+ A leaf: imports nothing from `stcode`, so anything in `core/` may import it.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import re
22
+ from pathlib import Path
23
+ from typing import Any, Literal
24
+
25
+ import tomlkit
26
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
27
+
28
+
29
+ # Enumerations shared by every layer
30
+ ApprovalMode = Literal["plan", "suggest", "auto-edit", "full-auto"]
31
+ Difficulty = Literal["low", "medium", "high"]
32
+ ProviderName = Literal["openai", "anthropic", "google"]
33
+ ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"]
34
+ TraceProvider = Literal["langfuse", "phoenix"]
35
+
36
+ APPROVAL_MODES: tuple[ApprovalMode, ...] = ("plan", "suggest", "auto-edit", "full-auto")
37
+ DIFFICULTIES: tuple[Difficulty, ...] = ("low", "medium", "high")
38
+ PROVIDER_NAMES: tuple[ProviderName, ...] = ("openai", "anthropic", "google")
39
+ REASONING_EFFORTS: tuple[ReasoningEffort, ...] = (
40
+ "none", "minimal", "low", "medium", "high", "xhigh", "max",
41
+ )
42
+
43
+ PROVIDER_KEY_ENV: dict[ProviderName, str] = {
44
+ "openai": "OPENAI_API_KEY",
45
+ "anthropic": "ANTHROPIC_API_KEY",
46
+ "google": "GEMINI_API_KEY",
47
+ }
48
+ """Where a library's key conventionally lives — the fallback when `api_key` is unset."""
49
+
50
+ ENV_TRUE = ("1", "true", "True", "yes")
51
+
52
+ DEFAULT_MAX_TURNS = 200
53
+ """Model/tool rounds per user turn; zero disables the ceiling."""
54
+
55
+
56
+ # Locations and secrets
57
+ def home_dir() -> Path:
58
+ """`~/.stcode`, or `%APPDATA%\\stcode` on Windows. Override with `STCODE_HOME`."""
59
+ env_path = os.environ.get("STCODE_HOME")
60
+ if env_path:
61
+ return Path(env_path).expanduser()
62
+ if os.name == "nt":
63
+ base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
64
+ return base / "stcode"
65
+ return Path.home() / ".stcode"
66
+
67
+
68
+ def config_path() -> Path:
69
+ """The config file: `$STCODE_CONFIG` when set, else `<home>/config.toml`."""
70
+ env_path = os.environ.get("STCODE_CONFIG")
71
+ if env_path:
72
+ return Path(env_path).expanduser()
73
+ return home_dir() / "config.toml"
74
+
75
+
76
+ _ENV_REF = re.compile(r"\$\{(\w+)\}")
77
+
78
+
79
+ def secret(value: SecretStr | str | None) -> str | None:
80
+ """A configured value, with a whole-value `"${ENV_VAR}"` reference resolved.
81
+
82
+ None for an unset value or an unset variable, so `secret(x) or fallback` reads well.
83
+ """
84
+ if value is None:
85
+ return None
86
+ text = (value.get_secret_value() if isinstance(value, SecretStr) else value).strip()
87
+ match = _ENV_REF.fullmatch(text)
88
+ if match:
89
+ return os.environ.get(match.group(1)) or None
90
+ return text or None
91
+
92
+
93
+ def is_reference(value: SecretStr | str | None) -> bool:
94
+ """Whether a value names an environment variable rather than holding the secret."""
95
+ if value is None:
96
+ return False
97
+ text = value.get_secret_value() if isinstance(value, SecretStr) else value
98
+ return bool(_ENV_REF.fullmatch(text.strip()))
99
+
100
+
101
+ # Sections
102
+ class DaemonConfig(BaseModel):
103
+ """Where the daemon listens.
104
+ Args:
105
+ transport: `unix` — socket via `socket` path, `tcp` — socket via `host` and `port`.
106
+ socket: Path to the Unix socket file. Ignored if `transport` is `tcp`.
107
+ host: Hostname to bind to. Ignored if `transport` is `unix`.
108
+ port: Port to bind to. Ignored if `transport` is `unix`.
109
+ max_clients: How many clients may be connected at once. 0 = unlimited. Unset =
110
+ 1 in a container, unlimited on the host.
111
+ """
112
+ transport: Literal["unix", "tcp"] = "unix"
113
+ socket: Path = Field(default_factory=lambda: home_dir() / "daemon.sock")
114
+ host: str = "127.0.0.1"
115
+ port: int = 7717
116
+ max_clients: int | None = Field(default=None, ge=0)
117
+
118
+ @property
119
+ def address(self) -> str:
120
+ if self.transport == "unix":
121
+ return str(self.socket.expanduser())
122
+ return f"{self.host}:{self.port}"
123
+
124
+
125
+ class AgentConfig(BaseModel):
126
+ """Limits on one turn of the loop.
127
+ Args:
128
+ reasoning_effort: How much reasoning to ask for from the LLM.
129
+ approval_mode: How much to gate tool calls on human approval.
130
+ max_turns: Maximum model/tool rounds in one turn. `0` = unlimited.
131
+ max_depth: How many sub-agent calls round in one turn. `1` = no recursion.
132
+ max_concurrent: How many LLM calls may run at once. `0` = unlimited.
133
+ enable_task: Enable sub-agent by include `task` tool.
134
+ difficulty: The tier main agent runs at.
135
+ tools: Allow-list of tools this deployment wants. Replaces the default.
136
+ exclude_tools: Subtraction from whatever is left after `tools`.
137
+ prompt: Inline prompt text for this agent profile.
138
+ prompt_file: Path to a prompt file. Relative to the config file's directory.
139
+ """
140
+ reasoning_effort: ReasoningEffort = "medium"
141
+ approval_mode: ApprovalMode = "suggest"
142
+ max_turns: int = Field(default=DEFAULT_MAX_TURNS, ge=0)
143
+ max_depth: int = Field(default=1, ge=1)
144
+ max_concurrent: int = Field(default=4, ge=0)
145
+ enable_task: bool = True
146
+ difficulty: Difficulty = "high"
147
+ tools: list[str] = Field(default_factory=list)
148
+ exclude_tools: list[str] = Field(default_factory=list)
149
+ prompt: str = ""
150
+ prompt_file: Path | None = None
151
+
152
+
153
+ class SessionConfig(BaseModel):
154
+ """Where transcripts live.
155
+ Args:
156
+ dir: Path to the session directory. Defaults to `~/.stcode/sessions`.
157
+ keep: How many sessions to keep before pruning. 0 = unlimited.
158
+ """
159
+ dir: Path = Field(default_factory=lambda: home_dir() / "sessions")
160
+ keep: int = Field(default=100, ge=0)
161
+
162
+
163
+ class ProviderConfig(BaseModel):
164
+ """One provider account. The table name is free (`[model.providers.work]`), so two
165
+ keys for one library are two entries.
166
+ Args:
167
+ provider: Which library talks to it.
168
+ model: The model used when no routing tier applies. Empty = the library's default.
169
+ api_key: A literal key or `"${ENV_VAR}"`. Unset = the library's conventional variable.
170
+ base_url: A proxy or compatible endpoint. Also accepts `"${ENV_VAR}"`.
171
+ max_concurrent: Requests this endpoint serves at once. 0 = no cap; 1 for a local model.
172
+ """
173
+ provider: ProviderName = "openai"
174
+ model: str = ""
175
+ api_key: SecretStr | None = None
176
+ base_url: str | None = None
177
+ max_concurrent: int = Field(default=0, ge=0)
178
+
179
+ def key(self) -> str | None:
180
+ return secret(self.api_key) or os.environ.get(PROVIDER_KEY_ENV[self.provider]) or None
181
+
182
+ def url(self) -> str | None:
183
+ return secret(self.base_url)
184
+
185
+
186
+ class RouteConfig(BaseModel):
187
+ """A difficulty tier's target.
188
+ Args:
189
+ provider: The name of an entry in `[model.providers]`.
190
+ model: The model to call.
191
+ """
192
+ provider: str
193
+ model: str
194
+
195
+
196
+ class RetryConfig(BaseModel):
197
+ """How the daemon retries failed LLM calls. Not middle dropouts.
198
+ Args:
199
+ max_attempts: How many times to retry a failed call. 1 = no retries.
200
+ base_delay: How long to wait before the first retry, in seconds.
201
+ max_delay: The maximum delay between retries, in seconds.
202
+ jitter: Whether to add random jitter to the delay.
203
+ """
204
+ max_attempts: int = Field(default=3, ge=1, le=10)
205
+ base_delay: float = Field(default=1.0, ge=0)
206
+ max_delay: float = Field(default=20.0, ge=0)
207
+ jitter: bool = True
208
+
209
+
210
+ class ModelConfig(BaseModel):
211
+ """LLM provider configuration.
212
+ Args:
213
+ default: The provider entry used when no routing tier applies. Empty = the first.
214
+ providers: Provider entries by name.
215
+ routing: Per-difficulty overrides. Optional.
216
+ retry: The retry configuration for failed LLM calls.
217
+ """
218
+ default: str = ""
219
+ providers: dict[str, ProviderConfig] = Field(default_factory=dict)
220
+ routing: dict[Difficulty, RouteConfig] = Field(default_factory=dict)
221
+ retry: RetryConfig = Field(default_factory=RetryConfig)
222
+
223
+ @model_validator(mode="after")
224
+ def check_names(self) -> ModelConfig:
225
+ named = [self.default] if self.default else []
226
+ named += [route.provider for route in self.routing.values()]
227
+ unknown = sorted({name for name in named if name not in self.providers})
228
+ if unknown:
229
+ raise ValueError(
230
+ f"[model] names provider(s) {', '.join(unknown)} with no "
231
+ f"[model.providers.<name>] entry. Configured: {', '.join(self.providers) or 'none'}."
232
+ )
233
+ return self
234
+
235
+ def default_name(self) -> str:
236
+ return self.default or next(iter(self.providers), "")
237
+
238
+ def route(self, difficulty: Difficulty) -> tuple[str, str]:
239
+ """`(provider entry, model)` for a tier: its routing line, else the default entry.
240
+ The model may be empty, meaning the library's default."""
241
+ route = self.routing.get(difficulty)
242
+ if route is not None:
243
+ return route.provider, route.model
244
+ name = self.default_name()
245
+ if not name:
246
+ raise ValueError("No provider configured. Add a [model.providers.<name>] table.")
247
+ return name, self.providers[name].model
248
+
249
+
250
+ class TeamConfig(BaseModel):
251
+ """Team mode config.
252
+ Args:
253
+ enabled: Whether team mode is on. Can also be set via `STCODE_TEAM=1`.
254
+ name: The team this agent belongs to. Written on its member card.
255
+ role: The agent's role name. Can also be set via `STCODE_ROLE`.
256
+ description: One line on what this agent does — what `find_teammate` shows.
257
+ shared_dir: Path to the shared directory for team mode.
258
+ url: Reserved for a team service, used instead of `shared_dir`. Not built yet.
259
+ wake_on_message: Whether the daemon starts a turn when a message arrives and the agent is idle.
260
+ poll_interval: How often to poll for new messages in seconds.
261
+ """
262
+ enabled: bool = False
263
+ name: str = ""
264
+ role: str = ""
265
+ description: str = ""
266
+ shared_dir: Path = Field(default_factory=lambda: home_dir() / "team")
267
+ url: str = ""
268
+ wake_on_message: bool = True
269
+ poll_interval: float = Field(default=1.0, gt=0)
270
+
271
+ @model_validator(mode="after")
272
+ def override_from_env(self) -> TeamConfig:
273
+ if os.environ.get("STCODE_TEAM") in ENV_TRUE:
274
+ self.enabled = True
275
+ self.role = os.environ.get("STCODE_ROLE") or self.role
276
+ return self
277
+
278
+
279
+ class SupervisorConfig(BaseModel):
280
+ """The second pair of eyes on the trajectory.
281
+ Args:
282
+ enabled: Whether the supervisor is on. Can also be set via `STCODE_SUPERVISOR=1`.
283
+ every: How many tool calls within a turn to count before checking for loops.
284
+ window: How many tool calls to remember for loop detection.
285
+ difficulty: The tier the supervisor runs at.
286
+ """
287
+ enabled: bool = False
288
+ every: int = Field(default=8, ge=1)
289
+ window: int = Field(default=80, ge=1)
290
+ difficulty: Difficulty = "low"
291
+
292
+ @model_validator(mode="after")
293
+ def override_enabled_from_env(self) -> SupervisorConfig:
294
+ if os.environ.get("STCODE_SUPERVISOR") in ENV_TRUE:
295
+ self.enabled = True
296
+ return self
297
+
298
+
299
+ class MCPConfig(BaseModel):
300
+ """How MCP servers reach the agent.
301
+ Args:
302
+ enabled: Whether MCP is on. Default `True`.
303
+ expose: `code` writes servers as importable files, `tools` advertises them per turn.
304
+ """
305
+ enabled: bool = True
306
+ expose: Literal["code", "tools"] = "code"
307
+
308
+
309
+ class TraceConfig(BaseModel):
310
+ """Direct HTTP observation provider. Unset values fall back to the provider's own
311
+ conventional environment variables.
312
+ Args:
313
+ enabled: Whether trace is on. Default `False`.
314
+ provider: Which trace provider to use. Default `langfuse`.
315
+ url: The base URL of the trace provider.
316
+ public_key: The public key for the trace provider. (Langfuse)
317
+ secret_key: The secret key for the trace provider. (Langfuse)
318
+ api_key: The API key for the trace provider. (Phoenix)
319
+ project_name: The project name for the trace provider. (Phoenix)
320
+ content: Whether to include message content in traces.
321
+ """
322
+ enabled: bool = False
323
+ provider: TraceProvider = "langfuse"
324
+ url: str = ""
325
+ public_key: SecretStr | None = None
326
+ secret_key: SecretStr | None = None
327
+ api_key: SecretStr | None = None
328
+ project_name: str = ""
329
+ content: bool = False
330
+
331
+ def resolved(self) -> dict[str, str]:
332
+ """Every value with its reference resolved and its conventional fallback applied."""
333
+ env = os.environ.get
334
+ if self.provider == "langfuse":
335
+ return {
336
+ "url": secret(self.url) or env("LANGFUSE_BASE_URL") or env("LANGFUSE_HOST")
337
+ or "https://cloud.langfuse.com",
338
+ "public_key": secret(self.public_key) or env("LANGFUSE_PUBLIC_KEY") or "",
339
+ "secret_key": secret(self.secret_key) or env("LANGFUSE_SECRET_KEY") or "",
340
+ }
341
+ return {
342
+ "url": secret(self.url) or env("PHOENIX_COLLECTOR_ENDPOINT") or "http://localhost:6006",
343
+ "api_key": secret(self.api_key) or env("PHOENIX_API_KEY") or "",
344
+ "project_name": self.project_name or env("PHOENIX_PROJECT_NAME") or "default",
345
+ }
346
+
347
+
348
+ class ToolConfig(BaseModel):
349
+ """Credentials for the built-in tools.
350
+ Args:
351
+ tavily_api_key: A literal key or `"${ENV_VAR}"`. Unset = `$TAVILY_API_KEY`.
352
+ """
353
+ tavily_api_key: SecretStr | None = None
354
+
355
+ def tavily_key(self) -> str:
356
+ return secret(self.tavily_api_key) or os.environ.get("TAVILY_API_KEY", "").strip()
357
+
358
+
359
+ class Config(BaseModel):
360
+ """The whole config file, as read from TOML.
361
+
362
+ `home`, `path` and `cwd` are where it came from, never written back: `home` holds
363
+ the config, sessions and agent profiles, `path` is the file itself, and `cwd` is
364
+ the workspace a session opens in when its client names none.
365
+
366
+ An unknown top-level section is an error, so a file in an older layout
367
+ (`[defaults]`, `[providers.*]`) says so instead of loading as an empty config.
368
+ """
369
+
370
+ model_config = ConfigDict(extra="forbid")
371
+
372
+ daemon: DaemonConfig = Field(default_factory=DaemonConfig)
373
+ agent: AgentConfig = Field(default_factory=AgentConfig)
374
+ model: ModelConfig = Field(default_factory=ModelConfig)
375
+ session: SessionConfig = Field(default_factory=SessionConfig)
376
+ team: TeamConfig = Field(default_factory=TeamConfig)
377
+ mcp: MCPConfig = Field(default_factory=MCPConfig)
378
+ supervisor: SupervisorConfig = Field(default_factory=SupervisorConfig)
379
+ trace: TraceConfig = Field(default_factory=TraceConfig)
380
+ tools: ToolConfig = Field(default_factory=ToolConfig)
381
+
382
+ home: Path = Field(default_factory=home_dir, exclude=True)
383
+ path: Path | None = Field(default=None, exclude=True)
384
+ cwd: Path = Field(default_factory=Path.cwd, exclude=True)
385
+
386
+
387
+ # Reading and writing
388
+ DEFAULT_CONFIG = """\
389
+ # stcode config. A secret is a literal or a "${ENV_VAR}" reference.
390
+ # The daemon rewrites single keys in place, so comments here survive.
391
+
392
+ [model]
393
+ # default = "openai" # the entry used when no [model.routing] tier applies
394
+
395
+ [model.providers.openai]
396
+ provider = "openai" # or "anthropic", "google"
397
+ api_key = "${OPENAI_API_KEY}"
398
+ # model = "gpt-5" # unset = the library's default
399
+ # base_url = "http://localhost:11434/v1"
400
+ # max_concurrent = 1 # for a local model
401
+
402
+ # [model.routing.low] # optional, per difficulty tier
403
+ # provider = "openai"
404
+ # model = "gpt-5-mini"
405
+
406
+ [agent]
407
+ reasoning_effort = "medium"
408
+ approval_mode = "suggest" # plan | suggest | auto-edit | full-auto
409
+ """
410
+
411
+
412
+ def load_config(
413
+ path: str | Path | None = None,
414
+ cwd: Path | None = None,
415
+ *,
416
+ overrides: dict[str, Any] | None = None,
417
+ create_if_missing: bool = False,
418
+ ) -> Config:
419
+ """Read and validate a config file.
420
+
421
+ `overrides` are dotted keys (`{"daemon.transport": "tcp"}`) applied on top for this
422
+ process only — command-line flags, never written back. `create_if_missing`
423
+ scaffolds `DEFAULT_CONFIG` first.
424
+ """
425
+ path = Path(path).expanduser() if path else config_path()
426
+ if create_if_missing and not path.exists():
427
+ _write(path, DEFAULT_CONFIG)
428
+ if not path.exists():
429
+ raise FileNotFoundError(f"No stcode config found at {path}.")
430
+ data = tomlkit.parse(path.read_text(encoding="utf-8")).unwrap()
431
+ for dotted, value in (overrides or {}).items():
432
+ _set(data, dotted, value, table=dict)
433
+ config = Config.model_validate(data)
434
+ config.home = path.parent
435
+ config.path = path
436
+ config.cwd = cwd or Path.cwd()
437
+ return config
438
+
439
+
440
+ def update_config(path: Path, changes: dict[str, Any]) -> None:
441
+ """Set dotted keys (`"agent.approval_mode"`) in the file; `None` removes one.
442
+
443
+ Only the named keys change — comments and everything else survive. Refused with a
444
+ `ValueError` before anything is written when the result would not load.
445
+ """
446
+ document = tomlkit.parse(path.read_text(encoding="utf-8")) if path.exists() else tomlkit.document()
447
+ for dotted, value in changes.items():
448
+ _set(document, dotted, value, table=lambda: tomlkit.table(is_super_table=True))
449
+ Config.model_validate(document.unwrap())
450
+ _write(path, tomlkit.dumps(document))
451
+
452
+
453
+ def _set(root: Any, dotted: str, value: Any, *, table: Any) -> None:
454
+ """Set (or, for `None`, remove) one dotted key, creating tables on the way."""
455
+ *parents, leaf = dotted.split(".")
456
+ for part in parents:
457
+ if part not in root:
458
+ root[part] = table()
459
+ root = root[part]
460
+ if value is None:
461
+ root.pop(leaf, None)
462
+ else:
463
+ root[leaf] = value
464
+
465
+
466
+ def _write(path: Path, text: str) -> None:
467
+ """Atomically, mode 0600: the file may hold a literal key."""
468
+ path.parent.mkdir(parents=True, exist_ok=True)
469
+ tmp = path.with_name(path.name + ".tmp")
470
+ tmp.write_text(text, encoding="utf-8")
471
+ tmp.chmod(0o600)
472
+ tmp.replace(path)
473
+
474
+
475
+ def resolve_prompt(config: Config) -> str:
476
+ """The `[agent]` prompt: inline `prompt`, else the contents of `prompt_file`."""
477
+ prompt, prompt_file = config.agent.prompt.strip(), config.agent.prompt_file
478
+ if prompt or prompt_file is None:
479
+ return prompt
480
+ target = prompt_file.expanduser()
481
+ if not target.is_absolute():
482
+ target = config.home / target
483
+ try:
484
+ return target.read_text(encoding="utf-8").strip()
485
+ except OSError as exc:
486
+ # Loud: a prompt file that did not mount is an agent that owns nothing, and it
487
+ # looks exactly like one that did until it starts working.
488
+ raise FileNotFoundError(
489
+ f"[agent] prompt_file points at {target}, which cannot be read ({exc})."
490
+ ) from exc
@@ -0,0 +1,32 @@
1
+ """
2
+ Daemon — a long-lived process holding agents, speaking JSONL over a socket.
3
+
4
+ async with Daemon(config) as daemon:
5
+ await daemon.serve_forever()
6
+
7
+ The inversion the design rests on: a UI is a client, not the owner. Detaching leaves
8
+ the agent working, several clients may watch one session, and moving to TCP in a
9
+ container is a config key rather than a rewrite. The client lives in `stcode/cli/`
10
+ and shares nothing with this package but the wire format.
11
+
12
+ Five modules — `protocol` (the wire), `runner` (one session and its watchers), `server`
13
+ (socket and registry), `autonomy` (the `full-auto` guard), `main` (the entry point).
14
+ """
15
+
16
+ from stcode.core.daemon.autonomy import AutonomyRefused, guard_autonomy, in_container
17
+ from stcode.core.daemon.protocol import ProtocolError, decode, encode, parse_client_message
18
+ from stcode.core.daemon.runner import SessionRunner
19
+ from stcode.core.daemon.server import Daemon, socket_path
20
+
21
+ __all__ = [
22
+ "AutonomyRefused",
23
+ "Daemon",
24
+ "ProtocolError",
25
+ "SessionRunner",
26
+ "decode",
27
+ "encode",
28
+ "guard_autonomy",
29
+ "in_container",
30
+ "parse_client_message",
31
+ "socket_path",
32
+ ]
@@ -0,0 +1,71 @@
1
+ """
2
+ `full-auto` without an approver runs only inside a container.
3
+
4
+ Code rather than a document, because a document cannot refuse. `guard_autonomy` runs
5
+ before the daemon binds and again per session. **There is no override flag.**
6
+
7
+ **`has_approver` is False for every daemon stcode ships**, TUI included. Under
8
+ `full-auto` nothing ever *asks* — `requires_approval` returns False for every permission
9
+ — so an attached human is a spectator. The parameter exists for a future policy object
10
+ that really would answer `approval_request`.
11
+
12
+ It raises `AutonomyRefused`, not `SystemExit`: the TUI creates sessions inside a worker
13
+ task, which swallows `SystemExit` and shows the user nothing.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from pathlib import Path
20
+
21
+ from stcode.core.configs import ApprovalMode
22
+
23
+ SANDBOX_ENV = "STCODE_SANDBOX"
24
+ """Set to 1 in the image (§9.4). The escape hatch for a container runtime we cannot
25
+ otherwise detect — gVisor, a rootless namespace, CI — and the reason this is not just a
26
+ `/.dockerenv` check. Setting it on your laptop is lying to yourself, not to us."""
27
+
28
+ CONTAINER_MARKERS = ("/.dockerenv", "/run/.containerenv")
29
+ _CGROUP_MARKERS = ("docker", "lxc", "kubepods", "containerd", "podman", "libpod")
30
+
31
+ REFUSAL = (
32
+ "full-auto with no approver means the agent runs arbitrary commands with your "
33
+ "privileges and nothing is asked first. Run it in a container (set "
34
+ f"{SANDBOX_ENV}=1 there), or use --mode auto-edit. There is no override flag."
35
+ )
36
+
37
+
38
+ class AutonomyRefused(Exception):
39
+ """`full-auto` was requested somewhere its blast radius is not contained."""
40
+
41
+
42
+ def in_container() -> bool:
43
+ """Whether this process looks contained: the env flag, a runtime marker file, or
44
+ pid 1's cgroup naming a container runtime."""
45
+ if os.environ.get(SANDBOX_ENV, "").strip().lower() in ("1", "true", "yes"):
46
+ return True
47
+ if any(Path(marker).exists() for marker in CONTAINER_MARKERS):
48
+ return True
49
+ try:
50
+ cgroup = Path("/proc/1/cgroup").read_text(encoding="utf-8", errors="replace")
51
+ except OSError:
52
+ return False
53
+ return any(marker in cgroup for marker in _CGROUP_MARKERS)
54
+
55
+
56
+ def guard_autonomy(mode: ApprovalMode, has_approver: bool = False) -> None:
57
+ """Refuse `full-auto` outside a container when nothing will approve anything."""
58
+ if mode != "full-auto" or has_approver:
59
+ return
60
+ if not in_container():
61
+ raise AutonomyRefused(REFUSAL)
62
+
63
+
64
+ __all__ = [
65
+ "CONTAINER_MARKERS",
66
+ "REFUSAL",
67
+ "SANDBOX_ENV",
68
+ "AutonomyRefused",
69
+ "guard_autonomy",
70
+ "in_container",
71
+ ]