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,249 @@
1
+ """
2
+ Mailbox — agent-to-agent messaging, with no protocol.
3
+
4
+ Containers share a volume, so a message is a **file**: sending writes JSON into
5
+ `/team/inbox/<role>/`, receiving is reading your own directory. Joining writes a card
6
+ to `/team/members/<role>.json`, and finding a teammate is reading those.
7
+
8
+ /team/members/backend-dev.json <- who it is, what it does
9
+ /team/inbox/backend-dev/01HX…-from-ba.json <- unread
10
+ /team/inbox/backend-dev/.read/… <- drained
11
+
12
+ No registry service, no routing table, no N² socket mesh. Join, list members and send
13
+ are the three operations a team service would also have to offer — see
14
+ docs/architecture/team.md.
15
+
16
+ **Messages carry `refs`, not content** — that one convention is what keeps a team's
17
+ token cost from growing with the square of its size. Delivery is a write then a rename,
18
+ so a reader never sees half a message. Nothing here imports the harness.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import socket
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import Any, Iterator
29
+
30
+ from pydantic import BaseModel, Field
31
+
32
+ # ULID: sorts by time, so an inbox drains oldest-first with no index.
33
+ from stcode.core.common.ids import new_id
34
+
35
+ DEFAULT_TEAM_DIR = "/team"
36
+ """Mounted into every container — see docs/architecture/team.md."""
37
+
38
+ INBOX = "inbox"
39
+ MEMBERS = "members"
40
+ KNOWLEDGE = "knowledge"
41
+ ARTIFACTS = "artifacts"
42
+ READ_DIRNAME = ".read"
43
+ """Where a drained message goes. Moved, not deleted — "who told it that?" is a question
44
+ you will ask."""
45
+
46
+
47
+ class TeamMessage(BaseModel):
48
+ """One message. Named `TeamMessage` because `Message` is already the provider wire
49
+ format, and two of those in one codebase is a bug waiting to happen."""
50
+
51
+ id: str = ""
52
+ sender: str = ""
53
+ to: str = ""
54
+ subject: str = ""
55
+ body: str = ""
56
+ refs: list[str] = Field(default_factory=list)
57
+ ts: str = ""
58
+
59
+ def render(self) -> str:
60
+ """As the receiving agent reads it."""
61
+ lines = [f"[message from {self.sender or 'unknown'}] {self.subject}"]
62
+ if self.body:
63
+ lines.append(self.body)
64
+ if self.refs:
65
+ lines.append("Refs: " + ", ".join(self.refs))
66
+ return "\n".join(lines)
67
+
68
+
69
+ class TeamMember(BaseModel):
70
+ """One agent's card: who it is and what it does. What `find_teammate` reads."""
71
+
72
+ role: str
73
+ team: str = ""
74
+ description: str = ""
75
+ host: str = ""
76
+ joined: str = ""
77
+
78
+ def render(self) -> str:
79
+ """As a teammate looking for someone reads it."""
80
+ line = f"{self.role} — {self.description or '(no description)'}"
81
+ return f"{line} (on {self.host})" if self.host else line
82
+
83
+
84
+ class Mailbox:
85
+ """One role's view of the shared volume: its own inbox, and everyone else's."""
86
+
87
+ def __init__(self, root: str | Path = DEFAULT_TEAM_DIR, role: str = "") -> None:
88
+ self.root = Path(root).expanduser()
89
+ self.role = role
90
+
91
+ # ---- where things are ----
92
+
93
+ @property
94
+ def inbox(self) -> Path:
95
+ return self.root / INBOX / self.role
96
+
97
+ @property
98
+ def knowledge(self) -> Path:
99
+ return self.root / KNOWLEDGE
100
+
101
+ @property
102
+ def artifacts(self) -> Path:
103
+ return self.root / ARTIFACTS
104
+
105
+ @property
106
+ def members_dir(self) -> Path:
107
+ return self.root / MEMBERS
108
+
109
+ def inbox_of(self, role: str) -> Path:
110
+ return self.root / INBOX / role
111
+
112
+ def ensure(self, *roles: str) -> None:
113
+ """Create the volume layout. Safe to call repeatedly."""
114
+ for path in (self.knowledge, self.artifacts, self.members_dir, self.root / INBOX):
115
+ path.mkdir(parents=True, exist_ok=True)
116
+ for role in (self.role, *roles):
117
+ if role:
118
+ self.inbox_of(role).mkdir(parents=True, exist_ok=True)
119
+
120
+ # ---- membership ----
121
+
122
+ def join(self, description: str = "", team: str = "") -> Path:
123
+ """Create this role's inbox and write its card. Returns the card's path.
124
+
125
+ A restart rewrites the card; stopping leaves it, because the inbox outlives the
126
+ process and mail sent while a role is down waits for it.
127
+ """
128
+ self.ensure()
129
+ member = TeamMember(
130
+ role=self.role,
131
+ team=team,
132
+ description=description.strip(),
133
+ host=socket.gethostname(),
134
+ joined=_now(),
135
+ )
136
+ path = self.members_dir / f"{self.role}.json"
137
+ _write_atomic(path, member.model_dump_json(indent=2))
138
+ return path
139
+
140
+ def members(self) -> list[TeamMember]:
141
+ """Every card on the volume, by role. One unreadable card is skipped, not fatal:
142
+ a broken file on a shared volume must not hide the whole team."""
143
+ if not self.members_dir.is_dir():
144
+ return []
145
+ found: list[TeamMember] = []
146
+ for path in sorted(self.members_dir.glob("*.json")):
147
+ try:
148
+ found.append(TeamMember.model_validate_json(path.read_text(encoding="utf-8")))
149
+ except (OSError, ValueError):
150
+ continue
151
+ return found
152
+
153
+ # ---- sending ----
154
+
155
+ def send(
156
+ self, to: str, subject: str, body: str = "", refs: list[str] | None = None
157
+ ) -> Path:
158
+ """Write one message into `to`'s inbox. Returns the file it landed in.
159
+
160
+ Write-then-rename, so a reader draining at the same moment sees either nothing
161
+ or a complete message.
162
+ """
163
+ target = self.inbox_of(to)
164
+ target.mkdir(parents=True, exist_ok=True)
165
+
166
+ message = TeamMessage(
167
+ id=new_id(),
168
+ sender=self.role,
169
+ to=to,
170
+ subject=subject,
171
+ body=body,
172
+ refs=list(refs or []),
173
+ ts=_now(),
174
+ )
175
+ path = target / f"{message.id}-from-{self.role or 'unknown'}.json"
176
+ _write_atomic(path, message.model_dump_json(indent=2))
177
+ return path
178
+
179
+ # ---- receiving ----
180
+
181
+ def pending(self) -> list[Path]:
182
+ """Unread message files, oldest first. What the daemon's watcher polls."""
183
+ if not self.inbox.is_dir():
184
+ return []
185
+ return sorted(
186
+ entry
187
+ for entry in self.inbox.iterdir()
188
+ if entry.is_file() and entry.suffix == ".json" and not entry.name.startswith(".")
189
+ )
190
+
191
+ def drain(self) -> list[TeamMessage]:
192
+ """Read every waiting message and mark it read. Called at the top of a turn.
193
+
194
+ An unparseable message is moved aside too: leaving it would mean re-draining it
195
+ every turn forever, a louder failure than losing one malformed file.
196
+ """
197
+ read_dir = self.inbox / READ_DIRNAME
198
+ messages: list[TeamMessage] = []
199
+ for path in self.pending():
200
+ try:
201
+ messages.append(TeamMessage.model_validate_json(path.read_text(encoding="utf-8")))
202
+ except (OSError, ValueError):
203
+ pass
204
+ read_dir.mkdir(parents=True, exist_ok=True)
205
+ try:
206
+ os.replace(path, read_dir / path.name)
207
+ except OSError:
208
+ continue
209
+ return messages
210
+
211
+ def history(self) -> Iterator[dict[str, Any]]:
212
+ """Everything this role has already read. For working out what happened."""
213
+ read_dir = self.inbox / READ_DIRNAME
214
+ if not read_dir.is_dir():
215
+ return
216
+ for path in sorted(read_dir.glob("*.json")):
217
+ try:
218
+ yield json.loads(path.read_text(encoding="utf-8"))
219
+ except (OSError, ValueError):
220
+ continue
221
+
222
+ def __repr__(self) -> str:
223
+ return f"Mailbox({self.root}, role={self.role!r})"
224
+
225
+
226
+ def _now() -> str:
227
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
228
+
229
+
230
+ def _write_atomic(path: Path, text: str) -> None:
231
+ """Write-then-rename, so a reader at the same moment sees the old file or the new
232
+ one, never half of either. The staging name starts with a dot and ends in
233
+ `.partial`, so no reader's `*.json` matches it."""
234
+ staging = path.with_name(f".{path.name}.partial")
235
+ staging.write_text(text, encoding="utf-8")
236
+ os.replace(staging, path)
237
+
238
+
239
+ __all__ = [
240
+ "ARTIFACTS",
241
+ "DEFAULT_TEAM_DIR",
242
+ "INBOX",
243
+ "KNOWLEDGE",
244
+ "MEMBERS",
245
+ "READ_DIRNAME",
246
+ "Mailbox",
247
+ "TeamMember",
248
+ "TeamMessage",
249
+ ]
@@ -0,0 +1,145 @@
1
+ """
2
+ `find_teammate` and `send_team_message` — the two tools team mode adds.
3
+
4
+ Everything else already exists: `/team/knowledge/` is a directory, so the ordinary file
5
+ tools work on it. What was missing is a way to find who owns the next step, and to tell
6
+ them something is ready.
7
+
8
+ Factories rather than module-level `@tool`s, for the same reason as `task`: each closes
9
+ over one role's `Mailbox`, keeping `core/harness` from importing `core/team`.
10
+
11
+ **The send docstring pushes `refs` over `body`, deliberately** — it is the only place
12
+ that rule can be taught, and a team's cost grows with N² if messages carry documents.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from stcode.core.harness.approvals import ToolPermission
21
+ from stcode.core.harness.context import HarnessContext
22
+ from stcode.core.harness.tools.base import Runtime, Tool, ToolError, tool
23
+ from stcode.core.team.mailbox import Mailbox, TeamMember
24
+
25
+ MAX_BODY = 2000
26
+ """Longer than this is a document, not a message. Refused rather than truncated —
27
+ truncating delivers something that reads complete and is not."""
28
+
29
+
30
+ def make_find_teammate_tool(mailbox: Mailbox) -> Tool[Any]:
31
+ """The `find_teammate` tool for one role."""
32
+
33
+ @tool(permission=ToolPermission.READ)
34
+ async def find_teammate(query: str = "") -> str:
35
+ """Find the teammates who could take a piece of work, before you hand it off.
36
+
37
+ Returns each matching teammate's role and what it does. Use the role as `to` in
38
+ `send_team_message`. Call it again rather than remembering an old answer:
39
+ teammates join while you work.
40
+
41
+ Args:
42
+ query: Words describing the work, e.g. "api contract" or "deploy". A
43
+ teammate matches if any word appears in its role or description. Leave
44
+ empty to list everyone.
45
+ """
46
+ others = [member for member in mailbox.members() if member.role != mailbox.role]
47
+ if not others:
48
+ return "Nobody else has joined the team yet. Check again later."
49
+
50
+ words = [word.lower() for word in query.split()]
51
+ matches = [member for member in others if _matches(member, words)]
52
+ if not matches:
53
+ return (
54
+ f"No teammate matches {query!r}. Everyone on the team:\n"
55
+ + "\n".join(member.render() for member in others)
56
+ )
57
+ return "\n".join(member.render() for member in matches)
58
+
59
+ return find_teammate
60
+
61
+
62
+ def make_send_team_message_tool(mailbox: Mailbox) -> Tool[Any]:
63
+ """The `send_team_message` tool for one role."""
64
+
65
+ @tool(permission=ToolPermission.WRITE)
66
+ async def send_team_message(
67
+ to: str,
68
+ subject: str,
69
+ body: str = "",
70
+ refs: list[str] | None = None,
71
+ runtime: Runtime[HarnessContext] = None, # type: ignore[assignment]
72
+ ) -> str:
73
+ """Hand off work to, or ask something of, another agent on this team.
74
+
75
+ Use `find_teammate` first to learn which role owns the work.
76
+
77
+ Keep `body` short. Anything long belongs in a file under `/team/knowledge/` or
78
+ `/team/artifacts/` — write it there first, then put the path in `refs`. Do not
79
+ paste file contents into a message: the recipient can read the path, and every
80
+ word you paste is paid for again on every turn it stays in their history.
81
+
82
+ Send when you have finished something someone is waiting for, when you need a
83
+ decision only another role can make, or when you have found something that
84
+ changes their work. Your role prompt says who to report to.
85
+
86
+ Args:
87
+ to: The recipient's role, exactly as `find_teammate` shows it.
88
+ subject: One line, so the recipient can decide whether to read it now.
89
+ body: A short note. Say what you need and by when. Leave it empty if the
90
+ refs speak for themselves.
91
+ refs: Paths under /team holding the detail. This is the payload; the body
92
+ is the covering note.
93
+ """
94
+ recipient = to.strip()
95
+ if not recipient:
96
+ raise ToolError("`to` must name a role. Use `find_teammate` to see who is on the team.")
97
+ if recipient == mailbox.role:
98
+ raise ToolError("That is your own inbox. Use a note in /team/knowledge/ instead.")
99
+
100
+ # A card, not merely an inbox: a message to a role that never joined would sit
101
+ # in a directory nobody drains, and the sender would believe it was delivered.
102
+ known = [member.role for member in mailbox.members() if member.role != mailbox.role]
103
+ if recipient not in known:
104
+ raise ToolError(
105
+ f"There is no {recipient!r} on this team. The roles that have joined are: "
106
+ f"{', '.join(known) or '(none yet)'}. Use `find_teammate` to find who owns the work."
107
+ )
108
+ if len(body) > MAX_BODY:
109
+ raise ToolError(
110
+ f"The body is {len(body)} characters, over the {MAX_BODY} limit. Write it "
111
+ "to /team/knowledge/ or /team/artifacts/ and send the path in `refs`."
112
+ )
113
+
114
+ missing = [ref for ref in (refs or []) if not _ref_exists(mailbox, ref)]
115
+ if missing:
116
+ raise ToolError(
117
+ f"These refs do not exist yet: {', '.join(missing)}. Write the file first — "
118
+ "a message pointing at nothing wastes the recipient's whole turn."
119
+ )
120
+
121
+ path = mailbox.send(recipient, subject.strip(), body.strip(), list(refs or []))
122
+ await runtime.progress(f"→ {recipient}: {subject}")
123
+ return f"Sent to {recipient}. It will be waiting at the start of their next turn ({path.name})."
124
+
125
+ return send_team_message
126
+
127
+
128
+ def _matches(member: TeamMember, words: list[str]) -> bool:
129
+ if not words:
130
+ return True
131
+ text = f"{member.role} {member.description}".lower()
132
+ return any(word in text for word in words)
133
+
134
+
135
+ def _ref_exists(mailbox: Mailbox, ref: Any) -> bool:
136
+ """Whether a ref points at something real. Both spellings are accepted — absolute
137
+ (`/team/knowledge/spec.md`) and relative to the volume (`knowledge/spec.md`)."""
138
+ text = str(ref)
139
+ try:
140
+ return Path(text).exists() or (mailbox.root / text.lstrip("/")).exists()
141
+ except OSError:
142
+ return False
143
+
144
+
145
+ __all__ = ["MAX_BODY", "make_find_teammate_tool", "make_send_team_message_tool"]
@@ -0,0 +1,229 @@
1
+ Metadata-Version: 2.5
2
+ Name: stcode
3
+ Version: 0.0.1a0
4
+ Summary: A coding agent that lives in a daemon, and a UI that is a client of it.
5
+ Project-URL: Homepage, https://ming-doan.github.io/stcode/
6
+ Project-URL: Documentation, https://ming-doan.github.io/stcode/docs/
7
+ Project-URL: Source, https://github.com/Ming-doan/stcode
8
+ Project-URL: Issues, https://github.com/Ming-doan/stcode/issues
9
+ Author-email: Minh Doan <quangminh57dng@gmail.com>
10
+ License-Expression: Apache-2.0
11
+ License-File: LICENSE
12
+ Keywords: agent,coding-agent,daemon,llm,mcp,tui
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: POSIX
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: anthropic>=0.122.0
25
+ Requires-Dist: asyncer>=0.0.18
26
+ Requires-Dist: docstring-parser>=0.18.0
27
+ Requires-Dist: google-genai>=2.18.1
28
+ Requires-Dist: httpx>=0.28.1
29
+ Requires-Dist: mcp>=2.1.1
30
+ Requires-Dist: openai>=3.0.0
31
+ Requires-Dist: pydantic>=2.13.4
32
+ Requires-Dist: python-dotenv>=1.2.3
33
+ Requires-Dist: ripgrep>=15.1.0
34
+ Requires-Dist: textual>=8.2.8
35
+ Requires-Dist: tomli-w>=1.2.0
36
+ Requires-Dist: tomli>=2.0.1; python_version < '3.11'
37
+ Requires-Dist: tomlkit>=0.15.1
38
+ Requires-Dist: typer>=0.27.1
39
+ Description-Content-Type: text/markdown
40
+
41
+ # stcode
42
+
43
+ A coding agent for the terminal, built the other way round: **the agent lives in a
44
+ daemon, and the UI is a client of it.**
45
+
46
+ Closing the window does not stop the work. Several terminals can watch the same session.
47
+ Moving the agent into a container is a config key — `transport = "tcp"` — not a rewrite.
48
+ And because a container is a real boundary, `full-auto` can exist without being a
49
+ foot-gun: the daemon refuses to start in that mode anywhere its blast radius is not
50
+ contained.
51
+
52
+ ---
53
+
54
+ ## Quickstart
55
+
56
+ ```bash
57
+ uv sync # Python 3.10+
58
+ uv run stcode # first run opens a one-time setup screen
59
+ ```
60
+
61
+ Full documentation: `uv run --group docs mkdocs serve`, or read
62
+ [`docs/`](docs/index.md).
63
+
64
+ Pick a provider, paste an API key (or leave it blank and export `ANTHROPIC_API_KEY` /
65
+ `OPENAI_API_KEY` / `GEMINI_API_KEY`), choose a model, and type.
66
+
67
+ ### The three shapes
68
+
69
+ ```bash
70
+ uv run stcode # UI + a daemon, started here if none is listening
71
+ uv run stcode --headless # the daemon alone — what a container runs
72
+ uv run stcode --daemonless # the UI alone, attached to a daemon elsewhere
73
+ ```
74
+
75
+ `--daemonless` never starts an agent locally: if nothing answers, it asks *which daemon?*
76
+ rather than quietly running the work on your laptop. `/connect` moves a running UI to a
77
+ different one.
78
+
79
+ ### Other commands
80
+
81
+ ```bash
82
+ uv run stcode sessions # recent sessions, newest first
83
+ uv run stcode --resume 01HX… # attach to one, live or from disk
84
+ uv run stcode config # where config lives and what it selects
85
+ ```
86
+
87
+ Useful flags: `--cwd`, `--mode` (`plan` | `suggest` | `auto-edit` | `full-auto`),
88
+ `--role` (which agent this is — the prompt comes from `[agent] prompt`), `--restart` (stop the local daemon and
89
+ start a fresh one), `--config` (the file a daemon started here reads), and `--transport` /
90
+ `--socket` / `--host` / `--port` to point at a daemon. The daemon itself is
91
+ `stcode-daemon`; `stcode --headless` runs it.
92
+
93
+ ### In the UI
94
+
95
+ | | |
96
+ | --- | --- |
97
+ | `shift+tab` | cycle approval mode — takes effect on the live session |
98
+ | `esc` | interrupt the turn in flight |
99
+ | `f2` / `/model` | provider, key, model |
100
+ | `/connect` | point this client at a different daemon |
101
+ | `/mode`, `/clear`, `/help`, `/quit` | |
102
+
103
+ ### Attaching to an agent in a container
104
+
105
+ ```bash
106
+ # in the container
107
+ docker run -e STCODE_SANDBOX=1 -e ANTHROPIC_API_KEY -p 7717:7717 \
108
+ -v "$PWD:/workspace" stcode # runs stcode-daemon on tcp:7717
109
+
110
+ # on your machine
111
+ uv run stcode --daemonless --transport tcp --host <container-ip> --port 7717
112
+ ```
113
+
114
+ ---
115
+
116
+ ## What it does differently
117
+
118
+ Four things, each traceable to a measurement. The full argument, with the numbers, is
119
+ [`CLAUDE.md`](CLAUDE.md) §2.
120
+
121
+ **MCP tools as code, not tool definitions.** Most agents ship MCP schemas in the prompt
122
+ prefix every turn: 10–30k tokens, forever. stcode writes them to
123
+ `.stcode/mcp_servers/<server>/<tool>.py` and lets the agent `grep` and import what it
124
+ needs. Measured on three servers and twelve tools: **9,869 → 0 chars per turn.**
125
+
126
+ **A supervisor that watches the trajectory.** The session JSONL is already the trajectory,
127
+ so the supervisor is just a reader. Free counting heuristics run first — the same call
128
+ three times, over half the calls failing, no file written in N turns — and only a firing
129
+ heuristic costs a model call.
130
+
131
+ **Agent-to-agent messaging with no protocol.** Containers share a volume, so a message is
132
+ a file. No registry, no routing table, no service discovery. Messages carry paths, not
133
+ payloads, which stops team token cost growing with N².
134
+
135
+ **Daemon-first.** Detach does not kill the agent. Approvals and questions are
136
+ request–response correlated by `execution_id` over the same JSONL framing as everything
137
+ else.
138
+
139
+ ### Honest comparison
140
+
141
+ | | Claude Code | Cline / Kilo | **stcode** |
142
+ | --- | --- | --- | --- |
143
+ | Tool quality (read/edit/grep/bash) | excellent | good | comparable |
144
+ | MCP cost | tool defs per turn | tool defs per turn | as code, 9.9k → 0 chars/turn |
145
+ | Runs headless in a container | partial | no | **yes** |
146
+ | Multi-agent team across containers | no | no | **yes** |
147
+ | Stagnation detection | no | no | **yes** |
148
+ | Maturity, polish, ecosystem | **far ahead** | ahead | behind, and will stay behind |
149
+
150
+ We are not competing on polish.
151
+
152
+ ---
153
+
154
+ ## How it fits together
155
+
156
+ ```
157
+ cli ┄┄ socket + JSONL ┄┄▶ core/daemon ──▶ core/agent ──▶ { core/session, core/harness, core/providers }
158
+ └──▶ core/team (team mode only)
159
+ core/harness ──▶ core/repl core/configs, core/common ◀── everyone in core/
160
+ ```
161
+
162
+ `cli/` and `core/` are separate programs that never import each other: the UI starts
163
+ `stcode-daemon` and speaks to it over the socket. Inside `core/`, dependencies point one
164
+ way, always. [`CLAUDE.md`](CLAUDE.md) §3 has the contract for each
165
+ component.
166
+
167
+ **Approval modes** gate what runs unattended: `plan` reads only, `suggest` asks before
168
+ every write and command, `auto-edit` auto-approves edits, `full-auto` asks nothing — and,
169
+ without an approver, starts only inside a container. That last rule is code
170
+ (`core/daemon/autonomy.py`), not a warning in a document, and there is no override flag.
171
+
172
+ **Sessions** live at `~/.stcode/sessions/<id>.jsonl`, one append per event, never
173
+ rewritten. Branching a session is `cp`.
174
+
175
+ ---
176
+
177
+ ## Status
178
+
179
+ All five phases are built: the turn loop, tools, transcript, daemon and TUI client; the
180
+ REPL and MCP-as-code; the stagnation supervisor; multi-container team mode. Each phase has
181
+ a smoke script proving its gate against real processes:
182
+
183
+ ```bash
184
+ uv run python smoke_repl.py # the REPL, and tool_out
185
+ uv run python smoke_mcp.py # three MCP servers, prompt prefix unchanged
186
+ uv run python smoke_supervisor.py # a loop caught and redirected
187
+ uv run python smoke_daemon.py # detach/re-attach, and the autonomy guard
188
+ uv run python smoke_team.py # two containers, one volume (needs Docker)
189
+ ```
190
+
191
+ They need a reachable model and are meant to be read as much as run — each is the
192
+ shortest correct example of driving its layer.
193
+
194
+ **What is not done:** the ~20 team-mode evaluation tasks in
195
+ [`docs/architecture/team.md`](docs/architecture/team.md#the-evaluation-set) are written
196
+ and have not been run. Until they are, whether team mode helps or merely spends 15× the
197
+ tokens is an open question. [`CLAUDE.md`](CLAUDE.md) §7 explains why that is the honest
198
+ thing to say about it.
199
+
200
+ ---
201
+
202
+ ## Development
203
+
204
+ ```bash
205
+ uv run pytest
206
+ ```
207
+
208
+ ```bash
209
+ uv run pytest -m live # the two tests that call a real provider
210
+ uv run --group docs mkdocs serve # the documentation site
211
+ ```
212
+
213
+ Conventions: Python 3.10+, `uv` for everything, type hints mandatory in `core/`, tests
214
+ in `tests/`, commit messages `area: what changed`. Every tool has a docstring because
215
+ **the docstring is the prompt the model reads**.
216
+
217
+ **Before writing code**, read
218
+ [how a change is made](docs/contributing/workflow.md) — spec → question → document →
219
+ test → implement → run → commit. The document comes before the test on purpose: a
220
+ feature whose page is hard to write is a feature whose shape is wrong.
221
+
222
+ Start with [`CLAUDE.md`](CLAUDE.md) — what the project is, what it bets on, and the
223
+ seven rules — then [`docs/architecture/`](docs/architecture/index.md) for the component
224
+ you are about to change, and [`docs/decisions/`](docs/decisions/index.md) for the
225
+ questions already settled.
226
+
227
+ ## Licence
228
+
229
+ Apache 2.0 — see [LICENSE](LICENSE).