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,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
+ ]