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,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`stcode-daemon` — the daemon as a process. What a container runs, and what the UI
|
|
3
|
+
starts in the background when nothing is listening.
|
|
4
|
+
|
|
5
|
+
stcode-daemon [--config PATH] [--transport unix|tcp] [--socket PATH]
|
|
6
|
+
[--host HOST] [--port N] [--mode MODE] [--role ROLE] [--team]
|
|
7
|
+
|
|
8
|
+
Flags apply to this process only and are never written to the config file. It logs to
|
|
9
|
+
stderr and stops on SIGTERM, SIGINT, or a `shutdown` message over the unix socket.
|
|
10
|
+
|
|
11
|
+
Exit codes: 0 stopped, 1 failed to start, 2 `full-auto` refused (rule 5).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import asyncio
|
|
18
|
+
import contextlib
|
|
19
|
+
import logging
|
|
20
|
+
import signal
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Sequence
|
|
24
|
+
|
|
25
|
+
from stcode import __version__
|
|
26
|
+
from stcode.core.configs import APPROVAL_MODES, Config, config_path, load_config
|
|
27
|
+
from stcode.core.daemon.autonomy import AutonomyRefused, in_container
|
|
28
|
+
from stcode.core.daemon.server import Daemon
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger("stcode.daemon")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|
34
|
+
parser = argparse.ArgumentParser(prog="stcode-daemon", description=__doc__.split("\n\n")[0])
|
|
35
|
+
parser.add_argument("--config", type=Path, help="Config file. Default: $STCODE_CONFIG or ~/.stcode/config.toml.")
|
|
36
|
+
parser.add_argument("--transport", choices=("unix", "tcp"))
|
|
37
|
+
parser.add_argument("--socket", help="Unix socket path.")
|
|
38
|
+
parser.add_argument("--host", help="TCP host to bind.")
|
|
39
|
+
parser.add_argument("--port", type=int, help="TCP port to bind.")
|
|
40
|
+
parser.add_argument("--mode", choices=APPROVAL_MODES, help="Default approval mode for new sessions.")
|
|
41
|
+
parser.add_argument("--role", help="Which agent this is. Does not turn team mode on.")
|
|
42
|
+
parser.add_argument("--team", action="store_true", default=None, help="Turn team mode on.")
|
|
43
|
+
parser.add_argument("--version", action="version", version=f"stcode {__version__}")
|
|
44
|
+
return parser.parse_args(argv)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def overrides_from(args: argparse.Namespace) -> dict[str, Any]:
|
|
48
|
+
"""The flags as dotted config keys, unset ones left out."""
|
|
49
|
+
pairs = {
|
|
50
|
+
"daemon.transport": args.transport,
|
|
51
|
+
"daemon.socket": args.socket,
|
|
52
|
+
"daemon.host": args.host,
|
|
53
|
+
"daemon.port": args.port,
|
|
54
|
+
"agent.approval_mode": args.mode,
|
|
55
|
+
"team.role": args.role,
|
|
56
|
+
"team.enabled": args.team,
|
|
57
|
+
}
|
|
58
|
+
return {key: value for key, value in pairs.items() if value is not None}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def startup_report(daemon: Daemon, *, created: Sequence[Path] = ()) -> list[str]:
|
|
62
|
+
"""What this daemon became, one line per fact — a container has no screen, so this
|
|
63
|
+
is the only place to say it. `created` names what start-up *made*: a config
|
|
64
|
+
scaffolded because a mount did not happen looks exactly like one that was mounted.
|
|
65
|
+
"""
|
|
66
|
+
config = daemon.config
|
|
67
|
+
limit = daemon.max_clients
|
|
68
|
+
provider, model = daemon.gateway().resolve(config.agent.difficulty)
|
|
69
|
+
lines = [
|
|
70
|
+
f"stcode {__version__} — daemon",
|
|
71
|
+
f" listening {config.daemon.transport} {daemon.address} "
|
|
72
|
+
+ (f"({limit} client{'' if limit == 1 else 's'} max)" if limit else "(no client limit)"),
|
|
73
|
+
f" workspace {config.cwd}",
|
|
74
|
+
f" mode {config.agent.approval_mode}",
|
|
75
|
+
f" model {model or '(library default)'} ({provider})",
|
|
76
|
+
f" config {config.path}",
|
|
77
|
+
f" sessions {config.session.dir}",
|
|
78
|
+
]
|
|
79
|
+
team = config.team
|
|
80
|
+
lines.append(f" team {team.role or '(no role!)'} on {team.shared_dir}" if team.enabled else " team off")
|
|
81
|
+
lines.append(f" container {'yes' if in_container() else 'no'}")
|
|
82
|
+
lines += [f" created {path}" for path in created]
|
|
83
|
+
return lines
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def serve(config: Config, overrides: dict[str, Any], created: Sequence[Path]) -> None:
|
|
87
|
+
daemon = Daemon(config, overrides=overrides)
|
|
88
|
+
await daemon.start()
|
|
89
|
+
loop = asyncio.get_running_loop()
|
|
90
|
+
for signum in (signal.SIGTERM, signal.SIGINT):
|
|
91
|
+
with contextlib.suppress(NotImplementedError): # Windows has no signal handlers
|
|
92
|
+
loop.add_signal_handler(signum, daemon.stop)
|
|
93
|
+
for line in startup_report(daemon, created=created):
|
|
94
|
+
print(line, file=sys.stderr)
|
|
95
|
+
print("ready", file=sys.stderr, flush=True)
|
|
96
|
+
try:
|
|
97
|
+
await daemon.serve_forever()
|
|
98
|
+
finally:
|
|
99
|
+
await daemon.aclose()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
103
|
+
args = parse_args(argv)
|
|
104
|
+
logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s", stream=sys.stderr)
|
|
105
|
+
overrides = overrides_from(args)
|
|
106
|
+
path = (args.config or config_path()).expanduser()
|
|
107
|
+
created = [] if path.exists() else [path]
|
|
108
|
+
try:
|
|
109
|
+
config = load_config(path, overrides=overrides, create_if_missing=True)
|
|
110
|
+
if not config.session.dir.expanduser().exists():
|
|
111
|
+
created.append(config.session.dir)
|
|
112
|
+
asyncio.run(serve(config, overrides, created))
|
|
113
|
+
except AutonomyRefused as refusal:
|
|
114
|
+
# Rule 5. The engine refuses; this only turns the refusal into an exit code.
|
|
115
|
+
print(str(refusal), file=sys.stderr)
|
|
116
|
+
return 2
|
|
117
|
+
except KeyboardInterrupt:
|
|
118
|
+
pass
|
|
119
|
+
except Exception as exc: # noqa: BLE001 — a daemon that cannot start says why and exits
|
|
120
|
+
print(f"stcode-daemon: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
121
|
+
return 1
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
sys.exit(main())
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The wire — JSONL, one message per line, same framing on unix and TCP.
|
|
3
|
+
|
|
4
|
+
Everything crossing a process boundary is shaped here and nowhere else.
|
|
5
|
+
|
|
6
|
+
* **One line, flushed** — the same discipline as the session file and the REPL worker.
|
|
7
|
+
Nothing on the wire may hold a raw newline, so the encoder is not optional.
|
|
8
|
+
* **Agent events are not re-wrapped.** `core/agent/events.py` already carries primitives
|
|
9
|
+
with the right `type`, so a `TextDelta` is dumped as-is plus a `session` field.
|
|
10
|
+
* `turn_finished` carries the **full four-field `Usage`** — the cache counts are the
|
|
11
|
+
evidence for the prompt-caching claim.
|
|
12
|
+
* `set_mode` and `set_meta` exist, so `/mode`, `/model` and `/effort` reach the live
|
|
13
|
+
session rather than only later ones.
|
|
14
|
+
* `info` exists because a client cannot answer "which skills are there" for itself —
|
|
15
|
+
in `--daemonless` the workspace is on the daemon's machine, not the terminal's.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from typing import Annotated, Any, Literal, Union
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
|
|
24
|
+
|
|
25
|
+
from stcode.core.agent.events import AgentEvent
|
|
26
|
+
from stcode.core.configs import ApprovalMode, ReasoningEffort
|
|
27
|
+
from stcode.core.harness.tools.base import ApprovalRequest, Question
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ProtocolError(Exception):
|
|
31
|
+
"""A line that is not a message this daemon understands."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---- client → daemon ------------------------------------------------------------
|
|
35
|
+
#
|
|
36
|
+
# Every message that acts on a session takes an optional `session` id. A connection
|
|
37
|
+
# remembers the last session it created or attached to and uses that when the field is
|
|
38
|
+
# empty, which is what §11.1's `{"type":"push","text":"…"}` assumes; the field is there
|
|
39
|
+
# because one connection may attach to several sessions at once and then has to say
|
|
40
|
+
# which one it means.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Create(BaseModel):
|
|
44
|
+
"""Start a new session. The daemon replies with `session` and attaches this client."""
|
|
45
|
+
|
|
46
|
+
type: Literal["create"] = "create"
|
|
47
|
+
cwd: str = ""
|
|
48
|
+
role: str = ""
|
|
49
|
+
approval_mode: ApprovalMode | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Attach(BaseModel):
|
|
53
|
+
"""Join a session. Several clients may attach to one at the same time.
|
|
54
|
+
|
|
55
|
+
An id the daemon is not holding is resumed from disk, which is what makes
|
|
56
|
+
`stcode --resume` a protocol message rather than a second code path.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
type: Literal["attach"] = "attach"
|
|
60
|
+
session: str = ""
|
|
61
|
+
replay: bool = True
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Detach(BaseModel):
|
|
65
|
+
"""Stop receiving this session's events. **Does not stop the agent** — that is the
|
|
66
|
+
entire reason the daemon exists (§8 point 2)."""
|
|
67
|
+
|
|
68
|
+
type: Literal["detach"] = "detach"
|
|
69
|
+
session: str = ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Sessions(BaseModel):
|
|
73
|
+
"""What this daemon is holding, plus what is on disk."""
|
|
74
|
+
|
|
75
|
+
type: Literal["sessions"] = "sessions"
|
|
76
|
+
limit: int = 20
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Push(BaseModel):
|
|
80
|
+
"""Queue a message. Arriving mid-turn, it waits for the next turn (§8 point 3)."""
|
|
81
|
+
|
|
82
|
+
type: Literal["push"] = "push"
|
|
83
|
+
text: str
|
|
84
|
+
session: str = ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Interrupt(BaseModel):
|
|
88
|
+
"""Stop the turn in flight at its next checkpoint."""
|
|
89
|
+
|
|
90
|
+
type: Literal["interrupt"] = "interrupt"
|
|
91
|
+
session: str = ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Approval(BaseModel):
|
|
95
|
+
"""The answer to an `approval_request`, correlated by `execution_id`."""
|
|
96
|
+
|
|
97
|
+
type: Literal["approval"] = "approval"
|
|
98
|
+
execution_id: str
|
|
99
|
+
approved: bool = False
|
|
100
|
+
session: str = ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Answer(BaseModel):
|
|
104
|
+
"""The answer to a `question`, correlated by `execution_id`."""
|
|
105
|
+
|
|
106
|
+
type: Literal["answer"] = "answer"
|
|
107
|
+
execution_id: str
|
|
108
|
+
text: str = ""
|
|
109
|
+
session: str = ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class SetMode(BaseModel):
|
|
113
|
+
"""Change a live session's approval mode."""
|
|
114
|
+
|
|
115
|
+
type: Literal["set_mode"] = "set_mode"
|
|
116
|
+
mode: ApprovalMode
|
|
117
|
+
session: str = ""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class SetMeta(BaseModel):
|
|
121
|
+
"""Change a live session's model, provider or reasoning effort.
|
|
122
|
+
|
|
123
|
+
A **patch**: an unset field is left alone, so `/effort` does not have to restate
|
|
124
|
+
the model. It lands as a `meta` record appended to the session, which is why the
|
|
125
|
+
change is in the transcript and nothing is rewritten.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
type: Literal["set_meta"] = "set_meta"
|
|
129
|
+
model: str | None = None
|
|
130
|
+
provider: str | None = None
|
|
131
|
+
reasoning_effort: ReasoningEffort | None = None
|
|
132
|
+
session: str = ""
|
|
133
|
+
|
|
134
|
+
def fields(self) -> dict[str, Any]:
|
|
135
|
+
"""Just the settings that were actually given."""
|
|
136
|
+
return {
|
|
137
|
+
key: value
|
|
138
|
+
for key, value in (
|
|
139
|
+
("model", self.model),
|
|
140
|
+
("provider", self.provider),
|
|
141
|
+
("reasoning_effort", self.reasoning_effort),
|
|
142
|
+
)
|
|
143
|
+
if value
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Info(BaseModel):
|
|
148
|
+
"""Ask what this daemon's machine has: skills, MCP servers, tools, paths."""
|
|
149
|
+
|
|
150
|
+
type: Literal["info"] = "info"
|
|
151
|
+
session: str = ""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class WorkspaceFiles(BaseModel):
|
|
155
|
+
"""List paths in the attached daemon workspace for client completion."""
|
|
156
|
+
|
|
157
|
+
type: Literal["workspace_files"] = "workspace_files"
|
|
158
|
+
session: str = ""
|
|
159
|
+
request_id: str
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class WorkspaceShell(BaseModel):
|
|
163
|
+
"""Run an explicit user command outside the agent and its history."""
|
|
164
|
+
|
|
165
|
+
type: Literal["workspace_shell"] = "workspace_shell"
|
|
166
|
+
session: str = ""
|
|
167
|
+
request_id: str
|
|
168
|
+
command: str
|
|
169
|
+
timeout: float = Field(default=10, allow_inf_nan=False)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class GetConfig(BaseModel):
|
|
173
|
+
"""Ask for the daemon's own config, as a `config` frame. Keys are never sent."""
|
|
174
|
+
|
|
175
|
+
type: Literal["get_config"] = "get_config"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class SetConfig(BaseModel):
|
|
179
|
+
"""Change the daemon's persisted defaults, save them, and reload.
|
|
180
|
+
|
|
181
|
+
A patch: an unset field is left alone. `provider` names a `[model.providers]` entry
|
|
182
|
+
— or a library name, which creates that entry — and becomes the default; `model`
|
|
183
|
+
and `api_key` apply to that entry. `api_key` is accepted over the unix socket only.
|
|
184
|
+
|
|
185
|
+
Answered with a `config` frame, whether or not the change was accepted.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
type: Literal["set_config"] = "set_config"
|
|
189
|
+
provider: str | None = None
|
|
190
|
+
model: str | None = None
|
|
191
|
+
api_key: str | None = None
|
|
192
|
+
reasoning_effort: ReasoningEffort | None = None
|
|
193
|
+
approval_mode: ApprovalMode | None = None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Shutdown(BaseModel):
|
|
197
|
+
"""Stop the daemon. Accepted over the unix socket only."""
|
|
198
|
+
|
|
199
|
+
type: Literal["shutdown"] = "shutdown"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
ClientMessage = Annotated[
|
|
203
|
+
Union[
|
|
204
|
+
Create,
|
|
205
|
+
Attach,
|
|
206
|
+
Detach,
|
|
207
|
+
Sessions,
|
|
208
|
+
Push,
|
|
209
|
+
Interrupt,
|
|
210
|
+
Approval,
|
|
211
|
+
Answer,
|
|
212
|
+
SetMode,
|
|
213
|
+
SetMeta,
|
|
214
|
+
Info,
|
|
215
|
+
WorkspaceFiles,
|
|
216
|
+
WorkspaceShell,
|
|
217
|
+
GetConfig,
|
|
218
|
+
SetConfig,
|
|
219
|
+
Shutdown,
|
|
220
|
+
],
|
|
221
|
+
Field(discriminator="type"),
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
_CLIENT_ADAPTER: TypeAdapter[ClientMessage] = TypeAdapter(ClientMessage)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---- daemon → client ------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class SessionOpened(BaseModel):
|
|
231
|
+
"""Answer to `create`, `attach`, `set_mode` and `set_meta`: what this session now is.
|
|
232
|
+
|
|
233
|
+
The settings are the **effective** ones — `meta` merged with any overrides — because
|
|
234
|
+
a status line showing what a session started as is a status line that lies after the
|
|
235
|
+
first `/model`. `started` says whether the transcript exists yet: a session can be
|
|
236
|
+
an id and nothing more until its first message.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
type: Literal["session"] = "session"
|
|
240
|
+
id: str
|
|
241
|
+
cwd: str = ""
|
|
242
|
+
role: str = ""
|
|
243
|
+
model: str = ""
|
|
244
|
+
provider: str = ""
|
|
245
|
+
reasoning_effort: str = ""
|
|
246
|
+
approval_mode: str = ""
|
|
247
|
+
busy: bool = False
|
|
248
|
+
started: bool = False
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class SessionList(BaseModel):
|
|
252
|
+
type: Literal["sessions"] = "sessions"
|
|
253
|
+
sessions: list[dict[str, Any]] = Field(default_factory=list)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class History(BaseModel):
|
|
257
|
+
"""The transcript so far, sent on attach before the live stream is joined.
|
|
258
|
+
|
|
259
|
+
Raw session records rather than `Message`s: a re-attaching client wants what the
|
|
260
|
+
trajectory holds — tool calls, errors, usage — not the folded model history.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
type: Literal["history"] = "history"
|
|
264
|
+
session: str = ""
|
|
265
|
+
records: list[dict[str, Any]] = Field(default_factory=list)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class ApprovalRequested(BaseModel):
|
|
269
|
+
"""A tool is waiting on a human. Answer with `approval` carrying this
|
|
270
|
+
`execution_id`; whoever answers first decides."""
|
|
271
|
+
|
|
272
|
+
type: Literal["approval_request"] = "approval_request"
|
|
273
|
+
session: str = ""
|
|
274
|
+
execution_id: str
|
|
275
|
+
tool: str
|
|
276
|
+
permission: str = ""
|
|
277
|
+
arguments: dict[str, Any] = Field(default_factory=dict)
|
|
278
|
+
agent_name: str = "main"
|
|
279
|
+
|
|
280
|
+
@classmethod
|
|
281
|
+
def of(cls, session: str, request: ApprovalRequest) -> "ApprovalRequested":
|
|
282
|
+
return cls(
|
|
283
|
+
session=session,
|
|
284
|
+
execution_id=request.execution_id,
|
|
285
|
+
tool=request.tool_name,
|
|
286
|
+
permission=str(request.permission),
|
|
287
|
+
arguments=request.arguments,
|
|
288
|
+
agent_name=request.agent_name,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class QuestionAsked(BaseModel):
|
|
293
|
+
"""`ask_user_question` is waiting. Answer with `answer`.
|
|
294
|
+
|
|
295
|
+
`execution_id` is minted by the daemon: `Question` has no field for one, unlike
|
|
296
|
+
`ApprovalRequest`. Same correlation mechanism, one end of it supplied here.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
type: Literal["question"] = "question"
|
|
300
|
+
session: str = ""
|
|
301
|
+
execution_id: str
|
|
302
|
+
question: str
|
|
303
|
+
header: str = ""
|
|
304
|
+
options: list[str] = Field(default_factory=list)
|
|
305
|
+
multi_select: bool = False
|
|
306
|
+
|
|
307
|
+
@classmethod
|
|
308
|
+
def of(cls, session: str, execution_id: str, question: Question) -> "QuestionAsked":
|
|
309
|
+
return cls(
|
|
310
|
+
session=session,
|
|
311
|
+
execution_id=execution_id,
|
|
312
|
+
question=question.question,
|
|
313
|
+
header=question.header,
|
|
314
|
+
options=question.options,
|
|
315
|
+
multi_select=question.multi_select,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class InfoReply(BaseModel):
|
|
320
|
+
"""Answer to `info`: what the **daemon's** machine has.
|
|
321
|
+
|
|
322
|
+
Every field here is something a client cannot see for itself once the agent is in a
|
|
323
|
+
container: the workspace, the config file, the skills that were discovered, the MCP
|
|
324
|
+
servers that answered. In solo mode that distinction is invisible; in
|
|
325
|
+
`--daemonless` it is the whole reason this message exists.
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
type: Literal["info"] = "info"
|
|
329
|
+
session: str = ""
|
|
330
|
+
version: str = ""
|
|
331
|
+
daemon: str = ""
|
|
332
|
+
cwd: str = ""
|
|
333
|
+
role: str = ""
|
|
334
|
+
config_path: str = ""
|
|
335
|
+
session_path: str = ""
|
|
336
|
+
session_dir: str = ""
|
|
337
|
+
approval_mode: str = ""
|
|
338
|
+
tools: list[str] = Field(default_factory=list)
|
|
339
|
+
skills: list[dict[str, str]] = Field(default_factory=list)
|
|
340
|
+
mcp: list[dict[str, Any]] = Field(default_factory=list)
|
|
341
|
+
usage: dict[str, int] = Field(default_factory=dict)
|
|
342
|
+
"""This session's token totals, summed from its `usage` records — what `/token`
|
|
343
|
+
shows. Here rather than as its own message because it is the same question `info`
|
|
344
|
+
already answers: *what does the daemon know about this session that I cannot see?*
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class ConfigReply(BaseModel):
|
|
349
|
+
"""Answer to `get_config` and `set_config`: what this daemon is configured with.
|
|
350
|
+
|
|
351
|
+
`path` is the file **on the daemon's disk**. `writable` says whether `set_config`
|
|
352
|
+
can land at all; `keys_editable` whether it may carry a key (unix socket only).
|
|
353
|
+
`provider` and `model` are the default entry, which is what `/model` edits.
|
|
354
|
+
|
|
355
|
+
`providers` lists the configured entries without their keys — `has_key` says
|
|
356
|
+
whether one resolves. `libraries` is what a new entry can be: each library's
|
|
357
|
+
default model and conventional key variable.
|
|
358
|
+
"""
|
|
359
|
+
|
|
360
|
+
type: Literal["config"] = "config"
|
|
361
|
+
path: str = ""
|
|
362
|
+
writable: bool = True
|
|
363
|
+
keys_editable: bool = False
|
|
364
|
+
provider: str = ""
|
|
365
|
+
model: str = ""
|
|
366
|
+
reasoning_effort: str = ""
|
|
367
|
+
approval_mode: str = ""
|
|
368
|
+
providers: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
|
369
|
+
libraries: dict[str, dict[str, str]] = Field(default_factory=dict)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class Progress(BaseModel):
|
|
373
|
+
"""A line of progress from a long-running tool. Advisory: nothing is recorded."""
|
|
374
|
+
|
|
375
|
+
type: Literal["progress"] = "progress"
|
|
376
|
+
session: str = ""
|
|
377
|
+
text: str = ""
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class ErrorMessage(BaseModel):
|
|
381
|
+
"""A protocol- or daemon-level failure. Not `agent_failed`, which is a turn ending
|
|
382
|
+
badly and is the agent's own event."""
|
|
383
|
+
|
|
384
|
+
type: Literal["error"] = "error"
|
|
385
|
+
session: str = ""
|
|
386
|
+
message: str
|
|
387
|
+
level: Literal["error", "warning"] = "error"
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
ServerMessage = Union[
|
|
391
|
+
SessionOpened,
|
|
392
|
+
SessionList,
|
|
393
|
+
History,
|
|
394
|
+
ApprovalRequested,
|
|
395
|
+
QuestionAsked,
|
|
396
|
+
InfoReply,
|
|
397
|
+
ConfigReply,
|
|
398
|
+
Progress,
|
|
399
|
+
ErrorMessage,
|
|
400
|
+
]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ---- framing --------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
STREAM_LIMIT = 64 * 1024 * 1024
|
|
406
|
+
"""How long one line may be, on both ends of every connection.
|
|
407
|
+
|
|
408
|
+
`asyncio`'s streams default to **64 KiB** per line, and a line over it raises
|
|
409
|
+
`ValueError` out of the read loop rather than arriving. The framing here is one message
|
|
410
|
+
per line, and `history` is one message carrying a whole transcript — so the default cap
|
|
411
|
+
means *a session stops being resumable the moment its file passes 64 KiB*, which is
|
|
412
|
+
about thirty tool calls. The frame is built in memory either way; this only says the
|
|
413
|
+
reader must be willing to receive what the writer was willing to send.
|
|
414
|
+
"""
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def encode(message: BaseModel | dict[str, Any]) -> bytes:
|
|
418
|
+
"""One message, one line, UTF-8. `default=str` so a stray Path or datetime in a
|
|
419
|
+
tool's arguments degrades to text instead of killing the connection."""
|
|
420
|
+
payload = message.model_dump(mode="json") if isinstance(message, BaseModel) else message
|
|
421
|
+
return (json.dumps(payload, ensure_ascii=False, default=str) + "\n").encode("utf-8")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def decode(line: bytes | str) -> dict[str, Any]:
|
|
425
|
+
"""One line back to an object, or `ProtocolError`."""
|
|
426
|
+
text = line.decode("utf-8", errors="replace") if isinstance(line, bytes) else line
|
|
427
|
+
try:
|
|
428
|
+
payload = json.loads(text)
|
|
429
|
+
except json.JSONDecodeError as exc:
|
|
430
|
+
raise ProtocolError(f"not JSON: {exc}") from exc
|
|
431
|
+
if not isinstance(payload, dict):
|
|
432
|
+
raise ProtocolError("a message must be a JSON object")
|
|
433
|
+
return payload
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def parse_client_message(payload: dict[str, Any]) -> ClientMessage:
|
|
437
|
+
"""Validate one decoded line as a client message.
|
|
438
|
+
|
|
439
|
+
Errors name the `type` the client sent, because "field required" against a model
|
|
440
|
+
the client has never heard of is not a diagnosis.
|
|
441
|
+
"""
|
|
442
|
+
try:
|
|
443
|
+
return _CLIENT_ADAPTER.validate_python(payload)
|
|
444
|
+
except ValidationError as exc:
|
|
445
|
+
kind = payload.get("type", "(missing)")
|
|
446
|
+
detail = "; ".join(
|
|
447
|
+
f"{'.'.join(str(part) for part in error['loc']) or 'message'}: {error['msg']}"
|
|
448
|
+
for error in exc.errors()
|
|
449
|
+
)
|
|
450
|
+
raise ProtocolError(f"bad {kind!r} message: {detail}") from exc
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def event_frame(session: str, event: AgentEvent, *, agent: str = "") -> dict[str, Any]:
|
|
454
|
+
"""An agent event as it goes on the wire: itself, plus where it came from.
|
|
455
|
+
|
|
456
|
+
`agent` names the **sub-agent** that produced it and is omitted when there is none,
|
|
457
|
+
so a frame without the field is the session's own agent. One extra field rather
|
|
458
|
+
than a parallel set of sub-agent message types — the same reason agent events are
|
|
459
|
+
not re-wrapped in the first place.
|
|
460
|
+
"""
|
|
461
|
+
frame = {**event.model_dump(mode="json"), "session": session}
|
|
462
|
+
if agent:
|
|
463
|
+
frame["agent"] = agent
|
|
464
|
+
return frame
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
__all__ = [
|
|
468
|
+
"STREAM_LIMIT",
|
|
469
|
+
"Answer",
|
|
470
|
+
"Approval",
|
|
471
|
+
"ApprovalRequested",
|
|
472
|
+
"Attach",
|
|
473
|
+
"ClientMessage",
|
|
474
|
+
"ConfigReply",
|
|
475
|
+
"Create",
|
|
476
|
+
"Detach",
|
|
477
|
+
"ErrorMessage",
|
|
478
|
+
"GetConfig",
|
|
479
|
+
"History",
|
|
480
|
+
"Info",
|
|
481
|
+
"InfoReply",
|
|
482
|
+
"Interrupt",
|
|
483
|
+
"Progress",
|
|
484
|
+
"ProtocolError",
|
|
485
|
+
"Push",
|
|
486
|
+
"QuestionAsked",
|
|
487
|
+
"ServerMessage",
|
|
488
|
+
"SessionList",
|
|
489
|
+
"SessionOpened",
|
|
490
|
+
"Sessions",
|
|
491
|
+
"SetConfig",
|
|
492
|
+
"SetMeta",
|
|
493
|
+
"SetMode",
|
|
494
|
+
"Shutdown",
|
|
495
|
+
"decode",
|
|
496
|
+
"encode",
|
|
497
|
+
"event_frame",
|
|
498
|
+
"parse_client_message",
|
|
499
|
+
]
|