zettcode-engine 0.3.4__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 (90) hide show
  1. zettcode/_compat.py +35 -0
  2. zettcode/budget.py +24 -0
  3. zettcode/config.py +1088 -0
  4. zettcode/config_edit.py +457 -0
  5. zettcode/engine/__init__.py +39 -0
  6. zettcode/engine/agent/__init__.py +81 -0
  7. zettcode/engine/agent/base.py +60 -0
  8. zettcode/engine/agent/context.py +166 -0
  9. zettcode/engine/agent/exports.py +73 -0
  10. zettcode/engine/agent/interaction.py +54 -0
  11. zettcode/engine/agent/sessions.py +218 -0
  12. zettcode/engine/agent/settings.py +269 -0
  13. zettcode/engine/agent/turns.py +308 -0
  14. zettcode/engine/core.py +819 -0
  15. zettcode/engine/dialogue/__init__.py +6 -0
  16. zettcode/engine/dialogue/ask.py +113 -0
  17. zettcode/engine/dialogue/side.py +197 -0
  18. zettcode/engine/dialogue/title.py +80 -0
  19. zettcode/engine/export/__init__.py +421 -0
  20. zettcode/engine/export/trace/script.js +18 -0
  21. zettcode/engine/export/trace/style.css +136 -0
  22. zettcode/engine/export/trace/template.html +31 -0
  23. zettcode/engine/extensions/__init__.py +8 -0
  24. zettcode/engine/extensions/capabilities.py +68 -0
  25. zettcode/engine/extensions/compaction.py +259 -0
  26. zettcode/engine/extensions/context.py +521 -0
  27. zettcode/engine/extensions/files.py +101 -0
  28. zettcode/engine/extensions/guidance.py +17 -0
  29. zettcode/engine/extensions/harness.py +208 -0
  30. zettcode/engine/extensions/harness_switch.py +18 -0
  31. zettcode/engine/extensions/mcp_notice.py +49 -0
  32. zettcode/engine/extensions/relay.py +93 -0
  33. zettcode/engine/extensions/subagents.py +391 -0
  34. zettcode/engine/extensions/tool_output.py +153 -0
  35. zettcode/engine/extensions/truncation.py +156 -0
  36. zettcode/engine/extensions/usage.py +304 -0
  37. zettcode/engine/harness_paths.py +74 -0
  38. zettcode/engine/permission/__init__.py +197 -0
  39. zettcode/engine/permission/approval.py +66 -0
  40. zettcode/engine/permission/readonly.py +435 -0
  41. zettcode/engine/permission/sandbox.py +140 -0
  42. zettcode/engine/pipeline.py +191 -0
  43. zettcode/engine/plugins/__init__.py +72 -0
  44. zettcode/engine/plugins/commands.py +152 -0
  45. zettcode/engine/plugins/context.py +251 -0
  46. zettcode/engine/plugins/extension.py +183 -0
  47. zettcode/engine/plugins/groups.py +74 -0
  48. zettcode/engine/plugins/loader.py +367 -0
  49. zettcode/engine/plugins/mixins.py +156 -0
  50. zettcode/engine/plugins/plugin.py +190 -0
  51. zettcode/engine/plugins/services.py +68 -0
  52. zettcode/engine/plugins/settings.py +117 -0
  53. zettcode/engine/plugins/state.py +64 -0
  54. zettcode/engine/plugins/switches.py +553 -0
  55. zettcode/engine/py.typed +0 -0
  56. zettcode/engine/server_tools.py +179 -0
  57. zettcode/engine/storage/__init__.py +51 -0
  58. zettcode/engine/storage/layout.py +161 -0
  59. zettcode/engine/storage/ledger.py +659 -0
  60. zettcode/engine/storage/locks.py +182 -0
  61. zettcode/engine/storage/metadata.py +278 -0
  62. zettcode/engine/storage/records.py +373 -0
  63. zettcode/engine/storage/store.py +1056 -0
  64. zettcode/engine/tools/__init__.py +7 -0
  65. zettcode/engine/tools/boundary.py +56 -0
  66. zettcode/engine/tools/files.py +262 -0
  67. zettcode/engine/tools/images.py +32 -0
  68. zettcode/engine/tools/output.py +81 -0
  69. zettcode/engine/tools/shell.py +181 -0
  70. zettcode/engine/tools/workspace.py +271 -0
  71. zettcode/error_text.py +217 -0
  72. zettcode/filelock.py +125 -0
  73. zettcode/logging_setup.py +196 -0
  74. zettcode/mention.py +124 -0
  75. zettcode/modes.py +18 -0
  76. zettcode/paths.py +113 -0
  77. zettcode/plugin_tui.py +133 -0
  78. zettcode/processes.py +126 -0
  79. zettcode/prompt.py +94 -0
  80. zettcode/providers.py +215 -0
  81. zettcode/py.typed +0 -0
  82. zettcode/registry.py +63 -0
  83. zettcode/relay.py +267 -0
  84. zettcode/statusline_parts.py +84 -0
  85. zettcode/version.py +11 -0
  86. zettcode/wire.py +30 -0
  87. zettcode/wording.py +11 -0
  88. zettcode_engine-0.3.4.dist-info/METADATA +23 -0
  89. zettcode_engine-0.3.4.dist-info/RECORD +90 -0
  90. zettcode_engine-0.3.4.dist-info/WHEEL +4 -0
@@ -0,0 +1,140 @@
1
+ """The operating-system sandbox a shell command runs in at the ``safe`` level.
2
+
3
+ ``safe`` is the middle rung between asking about everything and running everything: a command runs without a
4
+ question, and what it can do is bounded by the kernel and not by anybody's reading of the command line. The
5
+ boundary is the one Codex draws for its workspace-write mode:
6
+
7
+ * it may write the workspace, and the system temporary directory (``/tmp`` and ``$TMPDIR``), and nothing else;
8
+ * it may read the rest of the machine, except the places credentials live (``~/.ssh``, ``~/.aws`` and the like);
9
+ * it has no network, unless the configuration says ``[shell] network = true``;
10
+ * the files that run code later outside it (``.git/hooks``, ``.git/config``, shell start-up files) stay read-only
11
+ even inside the workspace.
12
+
13
+ The enforcement is ``zett-agent``'s: Seatbelt (``sandbox-exec``) on macOS and bubblewrap on Linux. Windows has
14
+ none, and neither has a Linux machine without ``bwrap``; there :attr:`ShellSandbox.available` is false and the
15
+ policy falls back to what ``safe`` was before the sandbox existed (read-only commands run, the rest ask), so the
16
+ word never promises a boundary the machine cannot draw.
17
+
18
+ The sandbox does not run the command. It rewrites it into one that starts under the sandbox's launcher, which the
19
+ shell tool then runs the way it runs any command, so the output handling, the timeout and the process-group kill
20
+ stay the framework's.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import shlex
26
+ import sys
27
+ import tempfile
28
+ from pathlib import Path
29
+
30
+ from zett_agent.sandbox import NetworkMode, SandboxPolicy, SandboxUnavailableError, create_sandbox
31
+ from zett_agent.sandbox.base import Sandbox
32
+
33
+ from ..tools.boundary import Sandboxer
34
+
35
+ __all__ = ["ShellSandbox", "SANDBOX_HINT", "temporary_roots"]
36
+
37
+ #: Appended to the failure of a command that ran sandboxed, so the model learns the boundary instead of
38
+ #: retrying the same call: a denied write and a missing network look like a bare "Operation not permitted".
39
+ SANDBOX_HINT = (
40
+ "[sandbox] This command ran in the `safe` sandbox: it can write only the workspace and the temporary "
41
+ "directory{network}. If it failed for that reason, tell the user which command needs more and let them "
42
+ "choose /permission full."
43
+ )
44
+
45
+
46
+ def temporary_roots() -> tuple[Path, ...]:
47
+ """Return the temporary directories a sandboxed command may write: ``/tmp`` and the process's own.
48
+
49
+ On macOS these are different places (``/tmp`` is ``/private/tmp``, and ``$TMPDIR`` is under
50
+ ``/var/folders``); on Linux they usually coincide. Only directories that exist are returned.
51
+ """
52
+ candidates = [Path("/tmp"), Path(tempfile.gettempdir())]
53
+ found: list[Path] = []
54
+ for path in candidates:
55
+ if path.is_dir() and path not in found:
56
+ found.append(path)
57
+ return tuple(found)
58
+
59
+
60
+ class ShellSandbox(Sandboxer):
61
+ """The sandbox for one workspace: whether this machine has one, and a command rewritten to run in it."""
62
+
63
+ def __init__(self, workspace: Path, *, network: bool = False) -> None:
64
+ """Describe the boundary; nothing is probed until :attr:`available` is first read.
65
+
66
+ Args:
67
+ workspace: The directory commands run in, which they may write.
68
+ network: Whether a sandboxed command may use the network.
69
+ """
70
+ self.workspace = workspace
71
+ self.network = network
72
+ self._sandbox: Sandbox | None = None
73
+ self._reason: str | None = None
74
+ self._probed = False
75
+
76
+ def _probe(self) -> None:
77
+ """Build the sandbox once and remember why not when the machine cannot."""
78
+ if self._probed:
79
+ return
80
+ self._probed = True
81
+ if not (sys.platform == "darwin" or sys.platform.startswith("linux")):
82
+ self._reason = "this platform has no sandbox; commands at `safe` are limited to read-only ones"
83
+ return
84
+ policy = SandboxPolicy.workspace_write(
85
+ self.workspace,
86
+ *temporary_roots(),
87
+ network=NetworkMode.FULL if self.network else NetworkMode.NONE,
88
+ )
89
+ try:
90
+ self._sandbox = create_sandbox(policy, require_isolation=True)
91
+ except SandboxUnavailableError as error:
92
+ self._reason = str(error)
93
+
94
+ @property
95
+ def available(self) -> bool:
96
+ """Return whether this machine can sandbox a command."""
97
+ self._probe()
98
+ return self._sandbox is not None
99
+
100
+ @property
101
+ def boundary(self) -> Sandbox | None:
102
+ """Return the sandbox itself, or ``None`` when this machine has none.
103
+
104
+ For a caller that runs code *inside* one rather than rewriting a command into it: the harness starts a
105
+ child process for the model's own tools, and at `safe` that child runs in this boundary.
106
+ """
107
+ self._probe()
108
+ return self._sandbox
109
+
110
+ @property
111
+ def unavailable_reason(self) -> str | None:
112
+ """Return why there is no sandbox, or ``None`` when there is one."""
113
+ self._probe()
114
+ return self._reason
115
+
116
+ def wrap(self, command: str, cwd: Path | None = None) -> str:
117
+ """Return ``command`` as a shell line that runs it inside the sandbox, in ``cwd``.
118
+
119
+ The environment the sandbox allows (no API keys or tokens) is spelled out with ``env -i``, because the
120
+ shell tool starts the line with the host's own environment.
121
+
122
+ ``cwd`` is where the command is meant to run — the tool's ``work_dir``, or the workspace. A Linux sandbox
123
+ puts ``--chdir`` in the argv it returns, and that wins over the ``cwd=`` of the spawn, so passing the
124
+ workspace for a command that runs somewhere else would run it in the wrong directory without a word. A
125
+ directory outside the workspace is outside the policy the sandbox writes, and the command will fail there
126
+ rather than be let out of it.
127
+
128
+ Raises:
129
+ RuntimeError: The machine has no sandbox; check :attr:`available` first.
130
+ """
131
+ self._probe()
132
+ if self._sandbox is None:
133
+ raise RuntimeError(self._reason or "no sandbox")
134
+ wrapped = self._sandbox.wrap(command, cwd=cwd if cwd is not None else Path.cwd())
135
+ assignments = [f"{name}={value}" for name, value in wrapped.env.items()]
136
+ return shlex.join(["env", "-i", *assignments, *wrapped.argv])
137
+
138
+ def hint(self) -> str:
139
+ """Return the note that goes after a failed sandboxed command."""
140
+ return SANDBOX_HINT.format(network="" if self.network else " and has no network")
@@ -0,0 +1,191 @@
1
+ """Work that must not hold the process, on threads nobody joins.
2
+
3
+ A launch is often abandoned before its first turn — read the screen, quit — and
4
+ what it was warming must not hold that exit open. What decides that is the kind
5
+ of thread the work runs on: the default executor's threads are joined while the
6
+ interpreter shuts down, so anything still in flight delays the process by its
7
+ whole duration — about a second for the model SDKs, seconds for a download —
8
+ while a daemon thread is left behind and the same exit takes milliseconds.
9
+ Neither leaves anything on stderr on the versions this project runs.
10
+
11
+ Two shapes of it live here: :class:`ImportPipeline`, for the modules a launch
12
+ wants before the first turn, and :class:`DaemonCall`, for one callable whose
13
+ answer may arrive after whoever asked for it has gone. Nothing is wasted either
14
+ way: what finished is a cache hit for the work that comes later, and what did not
15
+ is what that work would have paid anyway, done while the reader is reading the
16
+ first screen — a pipeline filling behind an instruction.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import importlib
23
+ import threading
24
+ from collections.abc import Callable, Sequence
25
+ from typing import Generic, TypeVar
26
+
27
+
28
+ class ImportPipeline:
29
+ """Import a list of modules, in order, on a daemon thread.
30
+
31
+ Args:
32
+ names: Dotted module names, imported in this order. A name that cannot be
33
+ imported is recorded in :attr:`failures` and the list carries on: a
34
+ module that is missing here is reported where it is actually used,
35
+ which is where a reader can act on it.
36
+
37
+ Note:
38
+ :meth:`start` returns without waiting for anything, and nothing else
39
+ waits for the thread either — a process that ends mid-list ends at once.
40
+ :meth:`wait` is for the code that needs the modules now, on the thread
41
+ that needs them.
42
+ """
43
+
44
+ def __init__(self, names: Sequence[str]) -> None:
45
+ """Hold the list; nothing starts until :meth:`start`."""
46
+ self._names = tuple(names)
47
+ self._finished = threading.Event()
48
+ self._stopped = threading.Event()
49
+ self._on_finish: Callable[[], None] | None = None
50
+ self._thread: threading.Thread | None = None
51
+ #: Modules that could not be imported, and why, keyed by module name.
52
+ self.failures: dict[str, BaseException] = {}
53
+
54
+ @property
55
+ def names(self) -> tuple[str, ...]:
56
+ """Return the modules this pipeline imports, in order."""
57
+ return self._names
58
+
59
+ @property
60
+ def done(self) -> bool:
61
+ """Return whether the list was worked through, or given up on."""
62
+ return self._finished.is_set()
63
+
64
+ def start(self, *, on_finish: Callable[[], None] | None = None) -> None:
65
+ """Begin importing, and return without waiting for any of it.
66
+
67
+ Args:
68
+ on_finish: Called on the pipeline's thread once the list is done or
69
+ given up on, so a caller can release whoever is waiting. It runs
70
+ off the event loop and must not touch it directly; a loop that
71
+ has closed in the meantime is not an error.
72
+ """
73
+ if self._thread is not None:
74
+ return
75
+ self._on_finish = on_finish
76
+ self._thread = threading.Thread(target=self._run, name="zettcode-imports", daemon=True)
77
+ self._thread.start()
78
+
79
+ def wait(self, timeout: float | None = None) -> bool:
80
+ """Block until the list is done, and report whether it finished in time."""
81
+ return self._finished.wait(timeout)
82
+
83
+ def stop(self) -> None:
84
+ """Ask the thread to give up at the next module boundary, and release waiters.
85
+
86
+ Called when the work is no longer wanted — the shell is going away and
87
+ the imports were for a turn that will not happen. The module being
88
+ imported is not interrupted, so the thread is never cut in half by this;
89
+ it notices between one module and the next.
90
+ """
91
+ self._stopped.set()
92
+ self._finished.set()
93
+ self._notify()
94
+
95
+ def _run(self) -> None:
96
+ try:
97
+ for name in self._names:
98
+ if self._stopped.is_set():
99
+ break
100
+ try:
101
+ importlib.import_module(name)
102
+ except Exception as error:
103
+ # A failure here has no reader: the module is reported by
104
+ # whatever needed it, which can say what it was doing.
105
+ self.failures[name] = error
106
+ finally:
107
+ self._finished.set()
108
+ self._notify()
109
+
110
+ def _notify(self) -> None:
111
+ """Tell the caller the list is over, from the pipeline's own thread."""
112
+ if self._on_finish is None:
113
+ return
114
+ try:
115
+ self._on_finish()
116
+ except RuntimeError:
117
+ # The loop this was reporting to closed while the imports ran: the
118
+ # shell that asked for them has already gone, and nobody is waiting.
119
+ pass
120
+
121
+
122
+ #: The value a callable hands back, of whatever type it is.
123
+ T = TypeVar("T")
124
+
125
+
126
+ class DaemonCall(Generic[T]):
127
+ """Run one callable on a daemon thread, and wait for it without owning it.
128
+
129
+ ``asyncio.to_thread`` does the same work, but its thread belongs to the
130
+ interpreter's default executor, which is joined while the process shuts down:
131
+ a call still running when the shell leaves delays the exit by its whole
132
+ duration. Here the thread is a daemon and the signal is an event, so
133
+ abandoning the call costs nothing — the answer is simply dropped, or picked
134
+ up later by :meth:`peek` if it arrived after all.
135
+
136
+ Args:
137
+ action: Called on the thread; its return value is what :meth:`wait`
138
+ answers.
139
+ """
140
+
141
+ def __init__(self, action: Callable[[], T]) -> None:
142
+ """Hold the callable; nothing runs until :meth:`start`."""
143
+ self._action = action
144
+ self._thread: threading.Thread | None = None
145
+ self._done: asyncio.Event | None = None
146
+ self._result: T | None = None
147
+ self._error: BaseException | None = None
148
+
149
+ @property
150
+ def started(self) -> bool:
151
+ """Return whether the call is on a thread."""
152
+ return self._thread is not None
153
+
154
+ def start(self) -> None:
155
+ """Begin the call and return at once, with the work on another thread."""
156
+ if self._thread is not None:
157
+ return
158
+ loop = asyncio.get_running_loop()
159
+ self._done = asyncio.Event()
160
+ self._thread = threading.Thread(target=self._run, args=(loop, self._done), name="zettcode-call", daemon=True)
161
+ self._thread.start()
162
+
163
+ def _run(self, loop: asyncio.AbstractEventLoop, done: asyncio.Event) -> None:
164
+ """Do the work, then release whoever waits, from this thread."""
165
+ try:
166
+ self._result = self._action()
167
+ except BaseException as error: # noqa: BLE001 - an answer of None covers every failure
168
+ self._error = error
169
+ finally:
170
+ try:
171
+ loop.call_soon_threadsafe(done.set)
172
+ except RuntimeError:
173
+ # The loop this was reporting to has closed: the shell that asked
174
+ # for the call has already gone, and nobody is waiting.
175
+ pass
176
+
177
+ async def wait(self, timeout: float | None = None) -> T | None:
178
+ """Wait for the answer, and return ``None`` if it failed or came too late."""
179
+ if self._done is None:
180
+ raise RuntimeError("DaemonCall.start() has not been called")
181
+ try:
182
+ await asyncio.wait_for(self._done.wait(), timeout)
183
+ except asyncio.TimeoutError:
184
+ return None
185
+ return None if self._error is not None else self._result
186
+
187
+ def peek(self) -> T | None:
188
+ """Return the answer if the call has finished, without waiting for it."""
189
+ if self._done is None or not self._done.is_set() or self._error is not None:
190
+ return None
191
+ return self._result
@@ -0,0 +1,72 @@
1
+ """The engine's plugin support: the base class, the context, the loader, and the switches.
2
+
3
+ A plugin needs only this package::
4
+
5
+ from zettcode.engine.plugins import Plugin, PluginContext
6
+
7
+ class Audit(Plugin):
8
+ name = "audit"
9
+
10
+ def activate(self, ctx: PluginContext) -> None:
11
+ ctx.agent.register_extension(AuditExtension())
12
+
13
+ What a plugin can register follows from the groups on ``ctx``: :attr:`PluginContext.agent` in every process, and
14
+ :attr:`PluginContext.tui` where the terminal is loading. A plugin that fills a header or status row by overriding a
15
+ hook is a :class:`zettcode.app.plugins.TuiPlugin`, which is this class plus that mixin. The plugins ZettCode composes
16
+ the agent from are not here: they are :data:`zettcode.sdk.builtins.AGENT_PLUGINS`, because they need the broker, the
17
+ config file and the MCP client, which the engine does not carry.
18
+
19
+ The pieces live in focused modules — :mod:`~zettcode.engine.plugins.mixins` for the hook groups,
20
+ :mod:`~zettcode.engine.plugins.context` for activation, :mod:`~zettcode.engine.plugins.plugin` for the base class, and
21
+ :mod:`~zettcode.engine.plugins.loader` for discovery — and are re-exported here as one surface.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from .context import PluginContext
27
+ from .extension import PluginExtension
28
+ from .loader import PLUGIN_ENTRY_POINT_GROUP, Loader, installed_entry_points, load_plugins
29
+ from .mixins import (
30
+ PluginAgentMixin,
31
+ PluginEventMixin,
32
+ PluginMaintenanceMixin,
33
+ PluginMiddlewareMixin,
34
+ PluginModelMixin,
35
+ PluginRunMixin,
36
+ PluginSetupMixin,
37
+ PluginToolMixin,
38
+ PluginTurnMixin,
39
+ )
40
+ from .plugin import Plugin, Plugins, PluginSetting
41
+ from .state import AgentBuild, Contribution, ExtensionFactory
42
+ from .switches import PluginEntry, applied, catalog, off_names, set_enabled, set_many
43
+
44
+ __all__ = [
45
+ "PLUGIN_ENTRY_POINT_GROUP",
46
+ "AgentBuild",
47
+ "Plugin",
48
+ "PluginContext",
49
+ "Contribution",
50
+ "ExtensionFactory",
51
+ "Loader",
52
+ "PluginAgentMixin",
53
+ "PluginEventMixin",
54
+ "PluginExtension",
55
+ "PluginMaintenanceMixin",
56
+ "PluginMiddlewareMixin",
57
+ "PluginModelMixin",
58
+ "PluginRunMixin",
59
+ "PluginSetupMixin",
60
+ "PluginToolMixin",
61
+ "PluginTurnMixin",
62
+ "Plugins",
63
+ "PluginEntry",
64
+ "PluginSetting",
65
+ "applied",
66
+ "catalog",
67
+ "installed_entry_points",
68
+ "load_plugins",
69
+ "off_names",
70
+ "set_enabled",
71
+ "set_many",
72
+ ]
@@ -0,0 +1,152 @@
1
+ """The commands a plugin may offer that say nothing about a front end.
2
+
3
+ A plugin registers a *plain* command on :attr:`~zettcode.engine.plugins.context.PluginContext.commands` when the
4
+ result is text: a paragraph to show and a one-line remark, or a request to start a turn. No widget, no page, no question put to a person — those
5
+ belong to the terminal, which registers its own kind of command on ``ctx.tui`` (see
6
+ :class:`zettcode.app.plugins.groups.TuiGroups`). Keeping the two apart is what lets a command be shown by a front end
7
+ that has no terminal: the handler cannot reach for a shell it will not have.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Awaitable, Callable
13
+ from dataclasses import dataclass
14
+ from typing import TypeAlias
15
+
16
+ __all__ = [
17
+ "CommandGroups",
18
+ "PlainCommand",
19
+ "PlainCommandContext",
20
+ "PlainCommandResult",
21
+ "PlainHandler",
22
+ "TextCommandResult",
23
+ "TurnCommandResult",
24
+ ]
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class PlainCommandContext:
29
+ """What a plain command's handler is handed.
30
+
31
+ Attributes:
32
+ argument: The words typed after the command's name.
33
+ level: The shell level the session is running at (``deny``, ``ask``, ``safe`` or ``full``), filled by the
34
+ front end that runs the command. A command that decides what to ask the model says it in these terms
35
+ rather than reaching for a shell it may not have.
36
+ """
37
+
38
+ argument: str = ""
39
+ level: str = "ask"
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class TextCommandResult:
44
+ """Words: Markdown for the conversation, an optional document, and a one-line remark.
45
+
46
+ ``message`` is what the conversation keeps, as before. ``markdown`` is a document for the front end to show on its
47
+ own — the terminal opens it as a page, the editor as a preview — for an answer too long to sit between two turns
48
+ or one a reader means to read rather than scroll past. It is a new field and not a new result type, so a front end
49
+ that predates it still runs the command and still shows ``message``.
50
+
51
+ Attributes:
52
+ message: Markdown added to the conversation.
53
+ notification: A one-line remark, as a toast.
54
+ markdown: A Markdown document shown by the front end in its own view; empty when there is none.
55
+ """
56
+
57
+ message: str = ""
58
+ notification: str = ""
59
+ markdown: str = ""
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class TurnCommandResult:
64
+ """A request to start rather than an answer: the text the model reads, and what the reader sees for it.
65
+
66
+ A command that is a preset for a turn — `/code-review` is the first — says so by returning this. The front end
67
+ that ran the command starts the turn with ``prompt``; ``shown`` is the short form the conversation keeps, which is
68
+ what the reader typed.
69
+
70
+ Attributes:
71
+ prompt: What the model reads.
72
+ shown: What the conversation keeps, when it is not ``prompt``.
73
+ notification: A one-line remark, as a toast.
74
+ cache: How the turn may use the provider's prompt cache: ``"read"`` for a one-off whose answer nobody will
75
+ follow, ``"read_write"`` to extend the cached prefix, ``"off"`` for no directive at all. Empty leaves the
76
+ front end's own default, which is ``"read_write"`` for a turn.
77
+ """
78
+
79
+ prompt: str
80
+ shown: str = ""
81
+ notification: str = ""
82
+ cache: str = ""
83
+
84
+
85
+ #: What a plain command's handler answers with. A failure is not a third case: it raises ``ValueError``, which every
86
+ #: front end already reports as the command's error line.
87
+ PlainCommandResult: TypeAlias = TextCommandResult | TurnCommandResult
88
+
89
+
90
+ #: A plain command's handler: sync or async, and it never needs a UI.
91
+ PlainHandler: TypeAlias = Callable[[PlainCommandContext], Awaitable[PlainCommandResult] | PlainCommandResult]
92
+
93
+
94
+ @dataclass(frozen=True, slots=True)
95
+ class PlainCommand:
96
+ """One registered plain command: the name typed, the summary shown, and what runs it.
97
+
98
+ ``when_busy`` is what a front end does with the command while a request is in flight (``"refuse"``,
99
+ ``"queue"`` or ``"now"``), and ``subcommands`` are the words it takes first, for a menu that offers them. Both are
100
+ the shell's vocabulary rather than a second one: a command that a process without a screen runs later should not
101
+ need a different spelling.
102
+ """
103
+
104
+ name: str
105
+ summary: str
106
+ handler: PlainHandler
107
+ when_busy: str = "refuse"
108
+ subcommands: tuple[object, ...] = ()
109
+
110
+
111
+ class CommandGroups:
112
+ """The plain commands one plugin offers, kept in registration order and unique by name."""
113
+
114
+ def __init__(self) -> None:
115
+ self._commands: dict[str, PlainCommand] = {}
116
+
117
+ @property
118
+ def commands(self) -> tuple[PlainCommand, ...]:
119
+ """Return the commands registered so far, in registration order."""
120
+ return tuple(self._commands.values())
121
+
122
+ def register(
123
+ self,
124
+ name: str,
125
+ summary: str,
126
+ handler: PlainHandler,
127
+ *,
128
+ when_busy: str = "refuse",
129
+ subcommands: tuple[object, ...] = (),
130
+ ) -> PlainCommand:
131
+ """Add one command that any front end can run.
132
+
133
+ Args:
134
+ name: The way it is typed, with its leading slash (``"/usage"``).
135
+ summary: One line for the command list.
136
+ handler: Runs with a :class:`PlainCommandContext` and returns a :class:`PlainCommandResult`.
137
+ when_busy: What a front end does when it is typed while a request is in flight.
138
+ subcommands: The words it takes first, offered by a menu that has one.
139
+
140
+ Raises:
141
+ ValueError: When the name or the summary is blank, or the name is already taken.
142
+ """
143
+ command = name.strip()
144
+ if not command or not command.startswith("/"):
145
+ raise ValueError(f"A command is typed with a leading slash, not {name!r}")
146
+ if not summary.strip():
147
+ raise ValueError(f"Command {command} needs a summary")
148
+ if command in self._commands:
149
+ raise ValueError(f"Command already registered: {command}")
150
+ registered = PlainCommand(command, summary.strip(), handler, when_busy, tuple(subcommands))
151
+ self._commands[command] = registered
152
+ return registered