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,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).
|