induscode 0.1.0__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.
- induscode/__init__.py +56 -0
- induscode/addons/__init__.py +176 -0
- induscode/addons/contract.py +923 -0
- induscode/addons/dispatch/__init__.py +43 -0
- induscode/addons/dispatch/event_dispatcher.py +348 -0
- induscode/addons/dispatch/tool_interceptor.py +349 -0
- induscode/addons/host.py +469 -0
- induscode/addons/loader.py +314 -0
- induscode/addons/manifest.py +232 -0
- induscode/addons/surface.py +199 -0
- induscode/boot/__init__.py +108 -0
- induscode/boot/auth_vault.py +323 -0
- induscode/boot/boot.py +210 -0
- induscode/boot/contract.py +223 -0
- induscode/boot/invocation.py +117 -0
- induscode/boot/runners/__init__.py +42 -0
- induscode/boot/runners/link_runner.py +82 -0
- induscode/boot/runners/oneshot_runner.py +85 -0
- induscode/boot/runners/registry.py +46 -0
- induscode/boot/runners/repl_runner.py +340 -0
- induscode/boot/runners/session.py +549 -0
- induscode/boot/stages.py +198 -0
- induscode/boot/upgrade/__init__.py +36 -0
- induscode/boot/upgrade/apply.py +125 -0
- induscode/boot/upgrade/upgrades.py +136 -0
- induscode/briefing/__init__.py +115 -0
- induscode/briefing/compose.py +414 -0
- induscode/briefing/contract.py +528 -0
- induscode/briefing/macros.py +721 -0
- induscode/briefing/skills.py +417 -0
- induscode/capability_deck/__init__.py +233 -0
- induscode/capability_deck/bridge_ledger/__init__.py +66 -0
- induscode/capability_deck/bridge_ledger/key.py +181 -0
- induscode/capability_deck/bridge_ledger/ledger.py +276 -0
- induscode/capability_deck/bridge_ledger/network.py +336 -0
- induscode/capability_deck/builtin_bridge.py +358 -0
- induscode/capability_deck/cards/__init__.py +116 -0
- induscode/capability_deck/cards/bg_process.py +482 -0
- induscode/capability_deck/cards/memory.py +226 -0
- induscode/capability_deck/cards/saas.py +280 -0
- induscode/capability_deck/cards/task.py +256 -0
- induscode/capability_deck/cards/todo.py +312 -0
- induscode/capability_deck/contract.py +450 -0
- induscode/capability_deck/manifest.py +126 -0
- induscode/capability_deck/provision.py +217 -0
- induscode/channels/__init__.py +146 -0
- induscode/channels/contract.py +585 -0
- induscode/channels/framer.py +132 -0
- induscode/channels/link/__init__.py +50 -0
- induscode/channels/link/dialog.py +246 -0
- induscode/channels/link/driver.py +308 -0
- induscode/channels/link/server.py +217 -0
- induscode/channels/oneshot.py +178 -0
- induscode/channels/ops.py +140 -0
- induscode/channels/session_ops.py +172 -0
- induscode/conductor/__init__.py +240 -0
- induscode/conductor/catalog.py +309 -0
- induscode/conductor/conductor.py +1084 -0
- induscode/conductor/contract.py +1035 -0
- induscode/conductor/matcher.py +291 -0
- induscode/conductor/serialize.py +575 -0
- induscode/conductor/signal_hub.py +382 -0
- induscode/conductor/skill_parse.py +294 -0
- induscode/conductor/transcript_store.py +449 -0
- induscode/console/__init__.py +236 -0
- induscode/console/app.py +1677 -0
- induscode/console/components/__init__.py +62 -0
- induscode/console/components/banner.py +499 -0
- induscode/console/components/banner_sweep.py +188 -0
- induscode/console/components/emblem.py +181 -0
- induscode/console/components/status_bar.py +102 -0
- induscode/console/contract.py +836 -0
- induscode/console/input/__init__.py +107 -0
- induscode/console/input/chord.py +197 -0
- induscode/console/input/dir_reader.py +113 -0
- induscode/console/input/intents.py +258 -0
- induscode/console/input/providers.py +469 -0
- induscode/console/mount.py +137 -0
- induscode/console/overlays/__init__.py +94 -0
- induscode/console/overlays/auth.py +503 -0
- induscode/console/overlays/pickers.py +526 -0
- induscode/console/overlays/router.py +129 -0
- induscode/console/overlays/sessions.py +232 -0
- induscode/console/reducer.py +145 -0
- induscode/console/resume_picker.py +156 -0
- induscode/console/slash_commands/__init__.py +78 -0
- induscode/console/slash_commands/builtins.py +254 -0
- induscode/console/slash_commands/dynamic.py +217 -0
- induscode/console/slash_commands/integrations.py +949 -0
- induscode/console/slash_commands/transcript.py +404 -0
- induscode/console/slash_commands/workbench.py +430 -0
- induscode/console/startup.py +434 -0
- induscode/console/theme/__init__.py +44 -0
- induscode/console/theme/adapter.py +168 -0
- induscode/console/theme/palette.py +128 -0
- induscode/console/theme/resolve.py +123 -0
- induscode/console/theme/tokens.py +185 -0
- induscode/console_slash/__init__.py +111 -0
- induscode/console_slash/contract.py +185 -0
- induscode/console_slash/registry.py +140 -0
- induscode/console_slash/resolve.py +194 -0
- induscode/console_slash/shared.py +172 -0
- induscode/entry.py +108 -0
- induscode/insight/__init__.py +153 -0
- induscode/insight/collector.py +73 -0
- induscode/insight/replay.py +305 -0
- induscode/insight/wrapper.py +1115 -0
- induscode/kit/__init__.py +82 -0
- induscode/kit/clipboard_image.py +215 -0
- induscode/kit/external_editor.py +120 -0
- induscode/kit/image.py +188 -0
- induscode/kit/shell.py +89 -0
- induscode/kit/tool_fetch.py +288 -0
- induscode/launch/__init__.py +224 -0
- induscode/launch/catalog.py +310 -0
- induscode/launch/contract.py +569 -0
- induscode/launch/credentials.py +852 -0
- induscode/launch/invocation/__init__.py +39 -0
- induscode/launch/invocation/attachments.py +281 -0
- induscode/launch/invocation/flags.py +210 -0
- induscode/launch/invocation/read.py +369 -0
- induscode/launch/invocation/usage.py +110 -0
- induscode/launch/oauth.py +808 -0
- induscode/launch/packages.py +299 -0
- induscode/launch/pickers.py +291 -0
- induscode/py.typed +0 -0
- induscode/runtime_bridge/__init__.py +166 -0
- induscode/runtime_bridge/bridges/__init__.py +66 -0
- induscode/runtime_bridge/bridges/_drive.py +268 -0
- induscode/runtime_bridge/bridges/builtins.py +177 -0
- induscode/runtime_bridge/bridges/claude_cli.py +198 -0
- induscode/runtime_bridge/bridges/codex_cli.py +203 -0
- induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
- induscode/runtime_bridge/broker.py +397 -0
- induscode/runtime_bridge/contract.py +734 -0
- induscode/runtime_bridge/sink.py +351 -0
- induscode/sessions/__init__.py +25 -0
- induscode/sessions/contract.py +119 -0
- induscode/sessions/library.py +350 -0
- induscode/settings/__init__.py +47 -0
- induscode/settings/contract.py +313 -0
- induscode/settings/manager.py +268 -0
- induscode/transcript_export/__init__.py +109 -0
- induscode/transcript_export/contract.py +522 -0
- induscode/transcript_export/publish.py +455 -0
- induscode/transcript_export/sgr.py +566 -0
- induscode/transcript_export/template.py +319 -0
- induscode/transcript_export/theme_bridge.py +325 -0
- induscode/window_budget/__init__.py +76 -0
- induscode/window_budget/budget/__init__.py +26 -0
- induscode/window_budget/budget/estimate.py +273 -0
- induscode/window_budget/budget/gate.py +60 -0
- induscode/window_budget/budget/slice.py +145 -0
- induscode/window_budget/condenser.py +170 -0
- induscode/window_budget/contract.py +329 -0
- induscode/window_budget/summarize/__init__.py +33 -0
- induscode/window_budget/summarize/condense.py +212 -0
- induscode/window_budget/summarize/prompt.py +241 -0
- induscode/workspace/__init__.py +30 -0
- induscode/workspace/brand.py +96 -0
- induscode/workspace/locator.py +269 -0
- induscode-0.1.0.dist-info/METADATA +97 -0
- induscode-0.1.0.dist-info/RECORD +167 -0
- induscode-0.1.0.dist-info/WHEEL +4 -0
- induscode-0.1.0.dist-info/entry_points.txt +3 -0
- induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
- induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
"""Addons contract — the FROZEN type surface of the third-party customization
|
|
2
|
+
layer.
|
|
3
|
+
|
|
4
|
+
This module is the single typed seam between the coding-agent *product* and
|
|
5
|
+
code that does not ship with it: locally-authored Python modules that graft
|
|
6
|
+
tools, slash commands, lifecycle observers, and tool-boundary interceptors
|
|
7
|
+
onto a running session. It declares *only* shapes plus a handful of tiny
|
|
8
|
+
inert helpers (a brand minter, a fault factory, and a pure empty-manifest
|
|
9
|
+
constructor) — no I/O, no module loading, no dispatch. Every later addons
|
|
10
|
+
module (the importlib-backed loader, the discovery walk, the surface builder,
|
|
11
|
+
the event dispatcher, and the interceptor chain) is written against the names
|
|
12
|
+
declared here, so the file is intentionally small, append-mostly, and stable.
|
|
13
|
+
|
|
14
|
+
Design stance (ported from TS ``src/addons/contract.ts``) — three deliberate
|
|
15
|
+
divergences from the prevailing "factory writes into per-addon Maps" pattern:
|
|
16
|
+
|
|
17
|
+
1. **Return-a-manifest registration.** An addon module's ``register`` is
|
|
18
|
+
handed an :class:`AddonSurface` and *records* its intent — subscriptions,
|
|
19
|
+
interceptors, commands, tools — but the registration calls do not mutate
|
|
20
|
+
any global runtime. The host reads the recorded :class:`RegisteredManifest`
|
|
21
|
+
back out and folds it into one shared registry. Registration is a pure
|
|
22
|
+
description of capability, not a side effect on the agent.
|
|
23
|
+
|
|
24
|
+
2. **One unified event model.** Lifecycle observation, payload
|
|
25
|
+
transformation, and veto are a single :class:`EventDispatcher` of
|
|
26
|
+
colon-named :data:`HookEvent` values, each carrying a :data:`HookHandler`
|
|
27
|
+
of one of three middleware kinds — ``observe`` (fire-and-forget),
|
|
28
|
+
``transform`` (returns a replacement payload), ``gate`` (returns a stop
|
|
29
|
+
decision). There is no second parallel "hooks" system; the dotted
|
|
30
|
+
vocabulary is replaced by colon names (``tool:before``, ``chat:params``,
|
|
31
|
+
``shell:env``, …).
|
|
32
|
+
|
|
33
|
+
3. **A tool-boundary pipeline.** The per-tool interception path is an
|
|
34
|
+
ordered :class:`InterceptorChain` of :class:`ToolInterceptor` stages with
|
|
35
|
+
``enter``/``exit`` hooks, applied as a reduce (enter forward, exit
|
|
36
|
+
reverse) — not a nested-if tool wrapper.
|
|
37
|
+
|
|
38
|
+
The module loader is **injectable** (:class:`ModuleLoader`): the default is
|
|
39
|
+
importlib-backed (see ``loader.py``), but a test injects a scripted fake that
|
|
40
|
+
returns an :class:`AddonManifest` with no real import and no disk access.
|
|
41
|
+
|
|
42
|
+
Port note — the jiti / virtual-module machinery is DROPPED
|
|
43
|
+
----------------------------------------------------------
|
|
44
|
+
The TS layer needed jiti (a TypeScript-on-the-fly importer) plus a
|
|
45
|
+
virtual-module/alias bridge so an addon's ``import "indusagi/agent"`` could
|
|
46
|
+
resolve inside a compiled single-file binary. Python addons are plain ``.py``
|
|
47
|
+
files and the ``indusagi`` framework is an installed package, so a plain
|
|
48
|
+
``import indusagi.agent`` simply works — no sandbox, no namespace bridge.
|
|
49
|
+
:data:`BUNDLED_NAMESPACES` is therefore **vestigial**: it is kept (renamed to
|
|
50
|
+
Python dotted module names, with ``@sinclair/typebox`` dropped — schemas are
|
|
51
|
+
plain JSON-schema mappings here) purely for parity and introspection; no
|
|
52
|
+
loader machinery consumes it.
|
|
53
|
+
|
|
54
|
+
Framework anchors (all from the ``indusagi`` package — the sibling rebuilt
|
|
55
|
+
framework this app targets):
|
|
56
|
+
|
|
57
|
+
- :class:`AgentTool`, :class:`AgentToolResult`, ``ThinkingLevel`` ← ``indusagi.agent``
|
|
58
|
+
- ``AgentMessage``, :class:`Model` ← ``indusagi.ai`` (note: ``AgentMessage``
|
|
59
|
+
lives in ``indusagi.ai``, not ``.agent`` — plan §5.9)
|
|
60
|
+
- :class:`Component`, ``KeyId`` ← ``indusagi.tui``
|
|
61
|
+
|
|
62
|
+
The contract never re-declares these; it composes them. The TS ``TSchema`` /
|
|
63
|
+
``Static`` re-exports collapse to the plain JSON-schema :data:`Schema` alias
|
|
64
|
+
(``Static`` is a compile-time computation with no Python analogue).
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
70
|
+
from dataclasses import dataclass
|
|
71
|
+
from typing import Any, ClassVar, Literal, NewType, Protocol, TypeAlias
|
|
72
|
+
|
|
73
|
+
from indusagi.agent import AgentTool, AgentToolResult, ThinkingLevel
|
|
74
|
+
from indusagi.ai import AgentMessage, Model
|
|
75
|
+
from indusagi.tui import Component, KeyId
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"ADDONS_DIR",
|
|
79
|
+
"AddonCommand",
|
|
80
|
+
"AddonDiscovery",
|
|
81
|
+
"AddonFault",
|
|
82
|
+
"AddonFaultKind",
|
|
83
|
+
"AddonFaultListener",
|
|
84
|
+
"AddonId",
|
|
85
|
+
"AddonManifest",
|
|
86
|
+
"AddonSource",
|
|
87
|
+
"AddonSurface",
|
|
88
|
+
"AddonTool",
|
|
89
|
+
"AgentMessage",
|
|
90
|
+
"AgentTool",
|
|
91
|
+
"AgentToolResult",
|
|
92
|
+
"ArgsRewrite",
|
|
93
|
+
"BUNDLED_NAMESPACES",
|
|
94
|
+
"BundledNamespace",
|
|
95
|
+
"CommandContext",
|
|
96
|
+
"CommandRun",
|
|
97
|
+
"CommandSpec",
|
|
98
|
+
"Component",
|
|
99
|
+
"DispatchOutcome",
|
|
100
|
+
"EnterFn",
|
|
101
|
+
"EnterOutcome",
|
|
102
|
+
"EventDispatcher",
|
|
103
|
+
"EventSubscription",
|
|
104
|
+
"ExecOutcome",
|
|
105
|
+
"ExecuteFn",
|
|
106
|
+
"ExitFn",
|
|
107
|
+
"ExitOutcome",
|
|
108
|
+
"FrameworkHandles",
|
|
109
|
+
"GateDecision",
|
|
110
|
+
"GateHandler",
|
|
111
|
+
"HookEvent",
|
|
112
|
+
"HookHandler",
|
|
113
|
+
"HookKind",
|
|
114
|
+
"InterceptResult",
|
|
115
|
+
"InterceptorChain",
|
|
116
|
+
"InterceptorStage",
|
|
117
|
+
"KeyId",
|
|
118
|
+
"Model",
|
|
119
|
+
"ModuleLoader",
|
|
120
|
+
"ObserveHandler",
|
|
121
|
+
"RegisteredManifest",
|
|
122
|
+
"Schema",
|
|
123
|
+
"ThinkingLevel",
|
|
124
|
+
"ToolEnterContext",
|
|
125
|
+
"ToolExitContext",
|
|
126
|
+
"ToolInterceptor",
|
|
127
|
+
"TransformHandler",
|
|
128
|
+
"addon_fault",
|
|
129
|
+
"addon_id",
|
|
130
|
+
"empty_manifest",
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# Schema stand-in (TypeBox collapses to plain JSON-schema mappings)
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
#: A JSON-schema parameter shape — the Python stand-in for TypeBox
|
|
139
|
+
#: ``TSchema``. The framework's tools carry their parameter schemas as plain
|
|
140
|
+
#: mappings; the addons layer threads them but never interprets them.
|
|
141
|
+
Schema: TypeAlias = Mapping[str, Any]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Bundled-namespace bridge identity (vestigial in Python — see module note)
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
#: The module specifiers an addon may import that the host guarantees are
|
|
149
|
+
#: importable. In TS these had to be *bridged* into a compiled binary via
|
|
150
|
+
#: jiti virtual modules; in Python they are ordinary installed packages and
|
|
151
|
+
#: ``import indusagi.agent`` just works, so this type is parity vocabulary
|
|
152
|
+
#: only. ``@sinclair/typebox`` is dropped (schemas are plain mappings).
|
|
153
|
+
BundledNamespace: TypeAlias = Literal[
|
|
154
|
+
"indusagi.agent",
|
|
155
|
+
"indusagi.ai",
|
|
156
|
+
"indusagi.tui",
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
#: The frozen list of :data:`BundledNamespace` specifiers, in a stable order.
|
|
160
|
+
#:
|
|
161
|
+
#: **Vestigial** in the Python port: no loader machinery consumes it (there is
|
|
162
|
+
#: no virtual-module bridge to build). Kept so diagnostics/introspection and
|
|
163
|
+
#: the TS lineage share one source of truth for "what an addon may rely on".
|
|
164
|
+
BUNDLED_NAMESPACES: tuple[BundledNamespace, ...] = (
|
|
165
|
+
"indusagi.agent",
|
|
166
|
+
"indusagi.ai",
|
|
167
|
+
"indusagi.tui",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# Addon identity & manifest
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
#: String-branded stable identifier for a loaded addon.
|
|
176
|
+
#:
|
|
177
|
+
#: Derived from the addon's declared ``id`` (or its source path when absent);
|
|
178
|
+
#: branded so an arbitrary string cannot be passed where a vetted addon id is
|
|
179
|
+
#: required. Mint one with :func:`addon_id`.
|
|
180
|
+
AddonId = NewType("AddonId", str)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def addon_id(raw: str) -> AddonId:
|
|
184
|
+
"""Brand a raw string as an :data:`AddonId`. The single sanctioned minter."""
|
|
185
|
+
return AddonId(raw)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class AddonManifest(Protocol):
|
|
189
|
+
"""What an addon module provides (or whose ``register`` the host invokes)
|
|
190
|
+
— the top-level shape the :class:`ModuleLoader` resolves.
|
|
191
|
+
|
|
192
|
+
An addon is a plain object (or the module itself), not a class hierarchy:
|
|
193
|
+
it declares its identity and a single ``register`` entry point.
|
|
194
|
+
``register`` receives an :class:`AddonSurface` and uses it to *record* the
|
|
195
|
+
addon's contributions; the host then reads those contributions back as a
|
|
196
|
+
:class:`RegisteredManifest`. The addon never reaches into global agent
|
|
197
|
+
state — its only channel is the surface it is handed.
|
|
198
|
+
|
|
199
|
+
Two optional attributes are duck-read by the host (``getattr``, tolerant
|
|
200
|
+
of absence — a Python ``Protocol`` cannot express optional members):
|
|
201
|
+
|
|
202
|
+
- ``id`` — stable string identifier; when omitted the host derives one
|
|
203
|
+
from the source path. Used for de-duplication, error attribution, and
|
|
204
|
+
conflict diagnostics.
|
|
205
|
+
- ``version`` — optional semantic version string, surfaced in
|
|
206
|
+
diagnostics and listings.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
def register(self, surface: AddonSurface) -> None | Awaitable[None]:
|
|
210
|
+
"""The addon's single entry point.
|
|
211
|
+
|
|
212
|
+
Invoked once at load time with a fresh :class:`AddonSurface`.
|
|
213
|
+
Synchronous or async; it records subscriptions / interceptors /
|
|
214
|
+
commands / tools onto the surface and returns nothing. Raising here is
|
|
215
|
+
captured as an :class:`AddonFault` and never crashes the host.
|
|
216
|
+
|
|
217
|
+
:param surface: the registration API scoped to this addon
|
|
218
|
+
"""
|
|
219
|
+
...
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
223
|
+
class RegisteredManifest:
|
|
224
|
+
"""The contributions an addon recorded through its :class:`AddonSurface`,
|
|
225
|
+
read back by the host after ``register`` runs — the return value of
|
|
226
|
+
registration, not a mutation of shared state.
|
|
227
|
+
|
|
228
|
+
This is the declarative result the host folds into its single capability
|
|
229
|
+
registry: the event subscriptions, the tool interceptors, the slash
|
|
230
|
+
commands, and the tools the addon contributed. Each entry carries enough
|
|
231
|
+
provenance (:data:`AddonId`) to attribute a later failure back to its
|
|
232
|
+
origin addon. Frozen, with tuple fields, so a read-back snapshot cannot be
|
|
233
|
+
mutated by a stray later surface call (the TS ``Object.freeze`` parity).
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
# The addon these contributions belong to.
|
|
237
|
+
addon: AddonId
|
|
238
|
+
# Optional version copied from the addon manifest.
|
|
239
|
+
version: str | None = None
|
|
240
|
+
# Event subscriptions the addon registered via AddonSurface.on.
|
|
241
|
+
subscriptions: tuple[EventSubscription, ...] = ()
|
|
242
|
+
# Tool interceptors the addon registered via AddonSurface.intercept_tool.
|
|
243
|
+
interceptors: tuple[ToolInterceptor, ...] = ()
|
|
244
|
+
# Slash commands the addon registered via AddonSurface.add_command.
|
|
245
|
+
commands: tuple[AddonCommand, ...] = ()
|
|
246
|
+
# Tools the addon registered via AddonSurface.add_tool.
|
|
247
|
+
tools: tuple[AddonTool, ...] = ()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def empty_manifest(addon: AddonId, version: str | None = None) -> RegisteredManifest:
|
|
251
|
+
"""An immutable, empty :class:`RegisteredManifest` for a given addon — the
|
|
252
|
+
seed an accumulating surface starts from, and a safe value to fold for an
|
|
253
|
+
addon that registered nothing.
|
|
254
|
+
|
|
255
|
+
:param addon: the addon this empty manifest is attributed to
|
|
256
|
+
:param version: optional version to carry through
|
|
257
|
+
"""
|
|
258
|
+
return RegisteredManifest(addon=addon, version=version)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
# Event taxonomy & handlers
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
#: The colon-named event vocabulary an addon can hook.
|
|
266
|
+
#:
|
|
267
|
+
#: One unified taxonomy replaces the split between broad lifecycle events and
|
|
268
|
+
#: low-level mutation hooks. Names are colon-segmented (``scope:phase``)
|
|
269
|
+
#: rather than dot-segmented, and a single event can be observed,
|
|
270
|
+
#: transformed, or gated depending on the handler kind an addon attaches:
|
|
271
|
+
#:
|
|
272
|
+
#: - ``session:start`` / ``session:end`` — a session opened / closed.
|
|
273
|
+
#: - ``turn:start`` / ``turn:end`` — an assistant turn began / settled.
|
|
274
|
+
#: - ``tool:before`` / ``tool:after`` — straddle a single tool execution.
|
|
275
|
+
#: - ``chat:params`` — the model request options are being built.
|
|
276
|
+
#: - ``chat:message`` — an assistant message was assembled.
|
|
277
|
+
#: - ``shell:env`` — the environment for a shell action is being prepared.
|
|
278
|
+
#: - ``input:submit`` — user input is entering the loop (transform/gate).
|
|
279
|
+
#: - ``context:build`` — the message context is being assembled for the model.
|
|
280
|
+
#: - ``compact:build`` — the condensed summary is being built.
|
|
281
|
+
#: - ``compact:before`` — the transcript is about to be condensed (gate).
|
|
282
|
+
HookEvent: TypeAlias = Literal[
|
|
283
|
+
"session:start",
|
|
284
|
+
"session:end",
|
|
285
|
+
"turn:start",
|
|
286
|
+
"turn:end",
|
|
287
|
+
"tool:before",
|
|
288
|
+
"tool:after",
|
|
289
|
+
"chat:params",
|
|
290
|
+
"chat:message",
|
|
291
|
+
"shell:env",
|
|
292
|
+
"input:submit",
|
|
293
|
+
"context:build",
|
|
294
|
+
"compact:build",
|
|
295
|
+
"compact:before",
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
#: The three middleware kinds a hook handler can be.
|
|
299
|
+
#:
|
|
300
|
+
#: - ``observe`` — fire-and-forget: inspect the payload, return nothing.
|
|
301
|
+
#: Cannot alter or veto; errors are isolated and never affect the flow.
|
|
302
|
+
#: - ``transform`` — return a replacement payload that the dispatcher threads
|
|
303
|
+
#: into the next handler (and ultimately back to the agent).
|
|
304
|
+
#: - ``gate`` — return a :class:`GateDecision`; ``stop=True``
|
|
305
|
+
#: short-circuits the event (vetoing a tool call, a compaction, a turn).
|
|
306
|
+
HookKind: TypeAlias = Literal["observe", "transform", "gate"]
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
310
|
+
class GateDecision:
|
|
311
|
+
"""A veto/short-circuit decision returned by a ``gate`` handler.
|
|
312
|
+
|
|
313
|
+
``stop=False`` (or an omitted return) lets the flow continue;
|
|
314
|
+
``stop=True`` halts it, with the optional ``reason`` surfaced to the
|
|
315
|
+
user/logs. Replaces the scattered ``cancel``/``block``/``reason`` result
|
|
316
|
+
fields with one shape.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
# Whether to halt the gated flow.
|
|
320
|
+
stop: bool
|
|
321
|
+
# Human-readable explanation when ``stop`` is True.
|
|
322
|
+
reason: str | None = None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@dataclass(frozen=True, slots=True)
|
|
326
|
+
class ObserveHandler:
|
|
327
|
+
"""A fire-and-forget hook handler: ``run`` inspects the payload and
|
|
328
|
+
returns nothing (sync or async). It cannot alter the value or stop the
|
|
329
|
+
chain; a raise is isolated into an :class:`AddonFault` and the run
|
|
330
|
+
continues."""
|
|
331
|
+
|
|
332
|
+
run: Callable[[Any], None | Awaitable[None]]
|
|
333
|
+
#: Discriminant tag — the middleware contract ``run`` honors.
|
|
334
|
+
kind: ClassVar[Literal["observe"]] = "observe"
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@dataclass(frozen=True, slots=True)
|
|
338
|
+
class TransformHandler:
|
|
339
|
+
"""A payload-rewriting hook handler: ``run`` returns a (possibly new)
|
|
340
|
+
payload of the same shape, which the dispatcher threads into every later
|
|
341
|
+
handler. A raise is isolated and treated as a no-op (the prior payload is
|
|
342
|
+
kept)."""
|
|
343
|
+
|
|
344
|
+
run: Callable[[Any], Any | Awaitable[Any]]
|
|
345
|
+
#: Discriminant tag — the middleware contract ``run`` honors.
|
|
346
|
+
kind: ClassVar[Literal["transform"]] = "transform"
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@dataclass(frozen=True, slots=True)
|
|
350
|
+
class GateHandler:
|
|
351
|
+
"""A vetoing hook handler: ``run`` returns a :class:`GateDecision` (or
|
|
352
|
+
``None`` for "no opinion"). The first ``stop=True`` short-circuits the
|
|
353
|
+
dispatch. A raise is isolated and the gate fails **open** (no veto)."""
|
|
354
|
+
|
|
355
|
+
run: Callable[[Any], GateDecision | None | Awaitable[GateDecision | None]]
|
|
356
|
+
#: Discriminant tag — the middleware contract ``run`` honors.
|
|
357
|
+
kind: ClassVar[Literal["gate"]] = "gate"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
#: The discriminated handler an addon attaches to a :data:`HookEvent`. The
|
|
361
|
+
#: TS tagged union ``{ kind, run }`` ports as three frozen dataclasses with
|
|
362
|
+
#: ``ClassVar`` literal tags; the dispatcher matches on ``handler.kind``.
|
|
363
|
+
HookHandler: TypeAlias = ObserveHandler | TransformHandler | GateHandler
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
367
|
+
class EventSubscription:
|
|
368
|
+
"""One recorded subscription: the event an addon hooked, the handler it
|
|
369
|
+
attached, and the addon it came from.
|
|
370
|
+
|
|
371
|
+
Produced by :meth:`AddonSurface.on` and collected into
|
|
372
|
+
:attr:`RegisteredManifest.subscriptions`. The :class:`EventDispatcher`
|
|
373
|
+
folds all subscriptions for an event into an ordered middleware run.
|
|
374
|
+
"""
|
|
375
|
+
|
|
376
|
+
# The event this subscription is bound to.
|
|
377
|
+
event: HookEvent
|
|
378
|
+
# The middleware handler invoked when the event fires.
|
|
379
|
+
handler: HookHandler
|
|
380
|
+
# The addon that registered this subscription, for error attribution.
|
|
381
|
+
addon: AddonId
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
385
|
+
class DispatchOutcome:
|
|
386
|
+
"""The result of dispatching one event through the :class:`EventDispatcher`.
|
|
387
|
+
|
|
388
|
+
``payload`` is the value after every ``transform`` handler has run
|
|
389
|
+
(identical to the input when none transformed it). ``gate``, when present,
|
|
390
|
+
is the first stopping :class:`GateDecision` a ``gate`` handler returned;
|
|
391
|
+
consumers treat ``gate.stop == True`` as a veto of whatever the event
|
|
392
|
+
guards.
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
# The payload after all transform middleware ran.
|
|
396
|
+
payload: Any
|
|
397
|
+
# The stopping gate decision, if any handler vetoed the flow.
|
|
398
|
+
gate: GateDecision | None = None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
class EventDispatcher(Protocol):
|
|
402
|
+
"""The fan-out / transform / veto engine that runs an event's
|
|
403
|
+
:class:`EventSubscription` list in order.
|
|
404
|
+
|
|
405
|
+
Unifies what were previously separate "emit events" and "trigger hooks"
|
|
406
|
+
systems into one surface. Dispatching an event threads its payload through
|
|
407
|
+
every matching handler: ``observe`` handlers see it untouched,
|
|
408
|
+
``transform`` handlers replace it, and a ``gate`` handler can stop the
|
|
409
|
+
chain. Errors from any handler are isolated into an :class:`AddonFault`
|
|
410
|
+
routed to listeners — never propagated into the agent loop.
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
async def dispatch(self, event: HookEvent, payload: Any) -> DispatchOutcome:
|
|
414
|
+
"""Run an event through its subscribed middleware and return the
|
|
415
|
+
outcome. Order is registration order across all addons.
|
|
416
|
+
|
|
417
|
+
:param event: the event to dispatch
|
|
418
|
+
:param payload: the initial payload threaded through the chain
|
|
419
|
+
"""
|
|
420
|
+
...
|
|
421
|
+
|
|
422
|
+
def has(self, event: HookEvent) -> bool:
|
|
423
|
+
"""Whether any addon has subscribed to the given event."""
|
|
424
|
+
...
|
|
425
|
+
|
|
426
|
+
def on_fault(self, listener: AddonFaultListener) -> Callable[[], None]:
|
|
427
|
+
"""Register a listener for per-handler :class:`AddonFault` reports.
|
|
428
|
+
Returns an unsubscribe."""
|
|
429
|
+
...
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# ---------------------------------------------------------------------------
|
|
433
|
+
# Tool-boundary interceptors
|
|
434
|
+
# ---------------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
438
|
+
class ToolEnterContext:
|
|
439
|
+
"""The arguments a tool is about to be invoked with, passed to a
|
|
440
|
+
:class:`ToolInterceptor` ``enter`` stage so it can inspect or rewrite
|
|
441
|
+
them.
|
|
442
|
+
|
|
443
|
+
``args`` is the decoded parameter mapping the agent built for this call;
|
|
444
|
+
a stage may return a replacement (or a :class:`GateDecision` to block the
|
|
445
|
+
call outright). Field names are this layer's own — not the framework's
|
|
446
|
+
tool-call schema.
|
|
447
|
+
"""
|
|
448
|
+
|
|
449
|
+
# Wire-facing name of the tool being invoked.
|
|
450
|
+
tool: str
|
|
451
|
+
# Stable id correlating this enter with its matching exit.
|
|
452
|
+
call_id: str
|
|
453
|
+
# The decoded parameter mapping the tool will execute against.
|
|
454
|
+
args: Mapping[str, Any]
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
458
|
+
class ToolExitContext:
|
|
459
|
+
"""The result a tool produced (or the error it raised), passed to a
|
|
460
|
+
:class:`ToolInterceptor` ``exit`` stage so it can inspect or rewrite the
|
|
461
|
+
outcome.
|
|
462
|
+
|
|
463
|
+
Carries the framework :class:`AgentToolResult` on success; on failure
|
|
464
|
+
``error`` is set and ``result`` is ``None``. An exit stage may return a
|
|
465
|
+
replacement result (e.g. to redact, annotate, or recover) which the chain
|
|
466
|
+
threads onward.
|
|
467
|
+
"""
|
|
468
|
+
|
|
469
|
+
# Wire-facing name of the tool that ran.
|
|
470
|
+
tool: str
|
|
471
|
+
# Stable id correlating this exit with its enter.
|
|
472
|
+
call_id: str
|
|
473
|
+
# The result the tool produced, when it succeeded.
|
|
474
|
+
result: AgentToolResult | None = None
|
|
475
|
+
# The error the tool raised, when it failed.
|
|
476
|
+
error: object | None = None
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
480
|
+
class ArgsRewrite:
|
|
481
|
+
"""An ``enter`` outcome that replaces the tool's decoded arguments.
|
|
482
|
+
|
|
483
|
+
The Python name for the TS anonymous ``{ args }`` shape: returning one
|
|
484
|
+
from an enter stage threads ``args`` into later stages and the real
|
|
485
|
+
execute.
|
|
486
|
+
"""
|
|
487
|
+
|
|
488
|
+
# The replacement decoded parameter mapping.
|
|
489
|
+
args: Mapping[str, Any]
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
#: What a :class:`ToolInterceptor` ``enter`` stage may return.
|
|
493
|
+
#:
|
|
494
|
+
#: - ``None`` — leave the args unchanged, continue the chain.
|
|
495
|
+
#: - an :class:`ArgsRewrite` — replace the arguments.
|
|
496
|
+
#: - a :class:`GateDecision` with ``stop=True`` — block the tool call.
|
|
497
|
+
#:
|
|
498
|
+
#: The chain also tolerates plain mappings carrying ``stop`` / ``args`` keys,
|
|
499
|
+
#: mirroring the TS structural guards.
|
|
500
|
+
EnterOutcome: TypeAlias = "None | ArgsRewrite | GateDecision"
|
|
501
|
+
|
|
502
|
+
#: What a :class:`ToolInterceptor` ``exit`` stage may return.
|
|
503
|
+
#:
|
|
504
|
+
#: - ``None`` — leave the outcome unchanged, continue the chain.
|
|
505
|
+
#: - a replacement :class:`AgentToolResult` — rewrite the tool's result.
|
|
506
|
+
ExitOutcome: TypeAlias = "AgentToolResult | None"
|
|
507
|
+
|
|
508
|
+
#: Pre-execution stage callback: inspect/rewrite args or block the call.
|
|
509
|
+
EnterFn: TypeAlias = Callable[[ToolEnterContext], "EnterOutcome | Awaitable[EnterOutcome]"]
|
|
510
|
+
|
|
511
|
+
#: Post-execution stage callback: inspect/rewrite the result.
|
|
512
|
+
ExitFn: TypeAlias = Callable[[ToolExitContext], "ExitOutcome | Awaitable[ExitOutcome]"]
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
516
|
+
class InterceptorStage:
|
|
517
|
+
"""The enter/exit pair an addon supplies to
|
|
518
|
+
:meth:`AddonSurface.intercept_tool` — the TS
|
|
519
|
+
``Omit<ToolInterceptor, "match" | "addon">`` shape. The surface fills the
|
|
520
|
+
``match`` and ``addon`` provenance fields around it.
|
|
521
|
+
"""
|
|
522
|
+
|
|
523
|
+
# Pre-execution stage: inspect/rewrite args or block the call.
|
|
524
|
+
enter: EnterFn | None = None
|
|
525
|
+
# Post-execution stage: inspect/rewrite the result.
|
|
526
|
+
exit: ExitFn | None = None
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
530
|
+
class ToolInterceptor:
|
|
531
|
+
"""A single stage in the tool-boundary :class:`InterceptorChain`.
|
|
532
|
+
|
|
533
|
+
Replaces the nested before/after wrapper with a composable stage that
|
|
534
|
+
straddles tool execution: ``enter`` runs before the tool (may rewrite args
|
|
535
|
+
or block), ``exit`` runs after (may rewrite the result). Either may be
|
|
536
|
+
omitted. The chain applies ``enter`` stages forward and ``exit`` stages in
|
|
537
|
+
reverse, so an addon loaded first wraps the outermost layer. Provenance is
|
|
538
|
+
carried for attribution.
|
|
539
|
+
"""
|
|
540
|
+
|
|
541
|
+
# Which tools this interceptor applies to: an exact name, or "*" for all.
|
|
542
|
+
match: str
|
|
543
|
+
# The addon that registered this interceptor, for error attribution.
|
|
544
|
+
addon: AddonId
|
|
545
|
+
# Pre-execution stage: inspect/rewrite args or block the call.
|
|
546
|
+
enter: EnterFn | None = None
|
|
547
|
+
# Post-execution stage: inspect/rewrite the result.
|
|
548
|
+
exit: ExitFn | None = None
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
552
|
+
class InterceptResult:
|
|
553
|
+
"""The outcome of running a tool through the :class:`InterceptorChain`.
|
|
554
|
+
|
|
555
|
+
On a normal run ``result`` holds the (possibly rewritten) tool result.
|
|
556
|
+
When an ``enter`` stage blocked the call, ``blocked`` carries the
|
|
557
|
+
:class:`GateDecision` and ``result`` is ``None`` — the tool never
|
|
558
|
+
executed.
|
|
559
|
+
"""
|
|
560
|
+
|
|
561
|
+
# The final tool result, when the call was allowed to run.
|
|
562
|
+
result: AgentToolResult | None = None
|
|
563
|
+
# The blocking decision, when an enter stage vetoed the call.
|
|
564
|
+
blocked: GateDecision | None = None
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
#: The real tool invocation the chain folds around, called with the final args.
|
|
568
|
+
ExecuteFn: TypeAlias = Callable[[Mapping[str, Any]], Awaitable[AgentToolResult]]
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
class InterceptorChain(Protocol):
|
|
572
|
+
"""The ordered pipeline that threads a single tool execution through every
|
|
573
|
+
matching :class:`ToolInterceptor`.
|
|
574
|
+
|
|
575
|
+
This is the tool-boundary integration point: it folds the registered
|
|
576
|
+
interceptors around the real tool ``execute``, running ``enter`` stages
|
|
577
|
+
forward (honoring an early block), executing the tool, then running
|
|
578
|
+
``exit`` stages in reverse. It replaces both the old tool wrapper and the
|
|
579
|
+
dotted before/after hook trigger with one reduce.
|
|
580
|
+
"""
|
|
581
|
+
|
|
582
|
+
async def run(self, ctx: ToolEnterContext, execute: ExecuteFn) -> InterceptResult:
|
|
583
|
+
"""Run a tool's execution through the interceptor pipeline.
|
|
584
|
+
|
|
585
|
+
Applies matching ``enter`` stages (rewriting args, or
|
|
586
|
+
short-circuiting on a block), invokes ``execute`` with the final args,
|
|
587
|
+
then applies matching ``exit`` stages in reverse. A blocked enter
|
|
588
|
+
resolves without calling ``execute``.
|
|
589
|
+
|
|
590
|
+
:param ctx: the entering call context (tool, call_id, initial args)
|
|
591
|
+
:param execute: the real tool invocation, called with the final args
|
|
592
|
+
"""
|
|
593
|
+
...
|
|
594
|
+
|
|
595
|
+
def matches(self, tool: str) -> bool:
|
|
596
|
+
"""Whether any interceptor matches the given tool name."""
|
|
597
|
+
...
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# ---------------------------------------------------------------------------
|
|
601
|
+
# Capabilities an addon contributes
|
|
602
|
+
# ---------------------------------------------------------------------------
|
|
603
|
+
|
|
604
|
+
#: A tool an addon contributes to the session.
|
|
605
|
+
#:
|
|
606
|
+
#: Deliberately an alias of the framework :class:`AgentTool` rather than a
|
|
607
|
+
#: fresh descriptor: the agent loop consumes ``AgentTool`` objects directly,
|
|
608
|
+
#: so an addon's job is to *supply* one, not to wrap it. (The TS generics over
|
|
609
|
+
#: ``TSchema``/details erase — the Python Protocol is structural and
|
|
610
|
+
#: ungeneric, so heterogeneous addon tools already coexist in one tuple.)
|
|
611
|
+
AddonTool: TypeAlias = AgentTool
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
615
|
+
class ExecOutcome:
|
|
616
|
+
"""The captured outcome of a :attr:`FrameworkHandles.exec` invocation.
|
|
617
|
+
|
|
618
|
+
A small, framework-agnostic shape: the process's standard streams and
|
|
619
|
+
exit code, so an addon can branch on success without parsing a richer
|
|
620
|
+
result.
|
|
621
|
+
"""
|
|
622
|
+
|
|
623
|
+
# Captured standard output.
|
|
624
|
+
stdout: str
|
|
625
|
+
# Captured standard error.
|
|
626
|
+
stderr: str
|
|
627
|
+
# Process exit code (0 on success), or None if terminated by signal.
|
|
628
|
+
code: int | None
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
632
|
+
class FrameworkHandles:
|
|
633
|
+
"""The bag of framework handles an addon may reach through — exposed on
|
|
634
|
+
the :class:`AddonSurface` and threaded into a :class:`CommandContext`.
|
|
635
|
+
|
|
636
|
+
These are the controlled callbacks an addon uses to act on the session
|
|
637
|
+
(instead of importing and mutating agent internals). Every handle is
|
|
638
|
+
optional: a print/JSON run mode supplies fewer than an interactive TUI.
|
|
639
|
+
The shapes are intentionally minimal — richer wiring is the host's
|
|
640
|
+
concern, not the contract's.
|
|
641
|
+
"""
|
|
642
|
+
|
|
643
|
+
# Inject an assistant-visible message into the active turn.
|
|
644
|
+
send_message: Callable[[str], None | Awaitable[None]] | None = None
|
|
645
|
+
# Switch the active model by canonical id.
|
|
646
|
+
set_model: Callable[[str], None | Awaitable[None]] | None = None
|
|
647
|
+
# Adjust the reasoning-effort level for subsequent turns.
|
|
648
|
+
set_thinking: Callable[[ThinkingLevel], None] | None = None
|
|
649
|
+
# Render an ephemeral TUI component (absent outside interactive mode).
|
|
650
|
+
render: Callable[[Component], None] | None = None
|
|
651
|
+
# Run a shell command and resolve its captured output.
|
|
652
|
+
exec: Callable[[str], Awaitable[ExecOutcome]] | None = None
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
656
|
+
class CommandContext:
|
|
657
|
+
"""Where a slash :class:`AddonCommand` runs and what it can reach.
|
|
658
|
+
|
|
659
|
+
Handed to a command handler at invocation time: the parsed argument
|
|
660
|
+
string, the workspace root, and a small set of framework handles (so the
|
|
661
|
+
command can send a message, switch the model, or drive the TUI) without
|
|
662
|
+
the addon holding global state. Optional handles are absent in
|
|
663
|
+
non-interactive run modes.
|
|
664
|
+
"""
|
|
665
|
+
|
|
666
|
+
# The raw argument string following the command name.
|
|
667
|
+
args: str
|
|
668
|
+
# Absolute working directory the session is scoped to.
|
|
669
|
+
cwd: str
|
|
670
|
+
# Framework handles the command may use (model switch, message send, TUI).
|
|
671
|
+
handles: FrameworkHandles
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
#: A command's entry point — runs against a :class:`CommandContext` and
|
|
675
|
+
#: returns nothing (sync or async). Deliberately a DIFFERENT shape from the
|
|
676
|
+
#: console's ``SlashCommand`` (which returns a ``SlashOutcome``): addon
|
|
677
|
+
#: commands describe an effect, not a console state transition.
|
|
678
|
+
CommandRun: TypeAlias = Callable[[CommandContext], None | Awaitable[None]]
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
682
|
+
class CommandSpec:
|
|
683
|
+
"""The definition an addon supplies to :meth:`AddonSurface.add_command` —
|
|
684
|
+
the TS ``Omit<AddonCommand, "name" | "addon">`` shape. The surface fills
|
|
685
|
+
the ``name`` and ``addon`` provenance fields around it.
|
|
686
|
+
"""
|
|
687
|
+
|
|
688
|
+
# One-line description for the command palette / help listing.
|
|
689
|
+
summary: str
|
|
690
|
+
# Execute the command against its invocation context.
|
|
691
|
+
run: CommandRun
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
695
|
+
class AddonCommand:
|
|
696
|
+
"""A slash command an addon contributes.
|
|
697
|
+
|
|
698
|
+
The model never calls a command — the user does, by name. ``name`` is the
|
|
699
|
+
invocation token (without a leading slash), ``summary`` lines it in help,
|
|
700
|
+
and ``run`` executes it against a :class:`CommandContext`. Provenance is
|
|
701
|
+
carried for conflict diagnostics when two addons claim the same name.
|
|
702
|
+
"""
|
|
703
|
+
|
|
704
|
+
# Invocation token (no leading slash), unique across loaded addons.
|
|
705
|
+
name: str
|
|
706
|
+
# One-line description for the command palette / help listing.
|
|
707
|
+
summary: str
|
|
708
|
+
# The addon that registered this command, for conflict attribution.
|
|
709
|
+
addon: AddonId
|
|
710
|
+
# Execute the command against its invocation context.
|
|
711
|
+
run: CommandRun
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
# ---------------------------------------------------------------------------
|
|
715
|
+
# The addon registration surface
|
|
716
|
+
# ---------------------------------------------------------------------------
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
class AddonSurface(Protocol):
|
|
720
|
+
"""The registration API a single addon receives in its
|
|
721
|
+
:meth:`AddonManifest.register` entry point.
|
|
722
|
+
|
|
723
|
+
Each method *records* a contribution rather than mutating global agent
|
|
724
|
+
state: the host hands a fresh surface to each addon, the addon calls
|
|
725
|
+
these to describe what it wants, and the host reads the accumulated
|
|
726
|
+
:class:`RegisteredManifest` back out via :meth:`manifest`. Read-only
|
|
727
|
+
access to framework handles is provided through :attr:`handles` for the
|
|
728
|
+
rare case an addon must act at registration time.
|
|
729
|
+
"""
|
|
730
|
+
|
|
731
|
+
@property
|
|
732
|
+
def id(self) -> AddonId:
|
|
733
|
+
"""The id of the addon this surface is scoped to."""
|
|
734
|
+
...
|
|
735
|
+
|
|
736
|
+
@property
|
|
737
|
+
def handles(self) -> FrameworkHandles:
|
|
738
|
+
"""Read-only access to the framework handles an addon may act through
|
|
739
|
+
at registration time. The same shape is threaded into command
|
|
740
|
+
contexts."""
|
|
741
|
+
...
|
|
742
|
+
|
|
743
|
+
def on(self, event: HookEvent, handler: HookHandler) -> None:
|
|
744
|
+
"""Subscribe a :data:`HookHandler` to a colon-named :data:`HookEvent`.
|
|
745
|
+
|
|
746
|
+
The handler's ``kind`` selects whether it observes, transforms, or
|
|
747
|
+
gates the event. Records an :class:`EventSubscription`.
|
|
748
|
+
|
|
749
|
+
:param event: the event to hook
|
|
750
|
+
:param handler: the observe/transform/gate middleware
|
|
751
|
+
"""
|
|
752
|
+
...
|
|
753
|
+
|
|
754
|
+
def intercept_tool(self, name: str, stage: InterceptorStage) -> None:
|
|
755
|
+
"""Register a :class:`ToolInterceptor` for the tool-boundary pipeline.
|
|
756
|
+
|
|
757
|
+
:param name: the tool name to intercept, or ``"*"`` for every tool
|
|
758
|
+
:param stage: the enter/exit stage (its ``match``/``addon`` are
|
|
759
|
+
filled by the surface)
|
|
760
|
+
"""
|
|
761
|
+
...
|
|
762
|
+
|
|
763
|
+
def add_command(self, name: str, spec: CommandSpec) -> None:
|
|
764
|
+
"""Register a slash command.
|
|
765
|
+
|
|
766
|
+
:param name: invocation token (no leading slash)
|
|
767
|
+
:param spec: the command definition (its ``name``/``addon`` are
|
|
768
|
+
filled by the surface)
|
|
769
|
+
"""
|
|
770
|
+
...
|
|
771
|
+
|
|
772
|
+
def add_tool(self, card: AddonTool) -> None:
|
|
773
|
+
"""Register an LLM-callable tool.
|
|
774
|
+
|
|
775
|
+
:param card: the framework :data:`AddonTool` to add to the session's deck
|
|
776
|
+
"""
|
|
777
|
+
...
|
|
778
|
+
|
|
779
|
+
def manifest(self) -> RegisteredManifest:
|
|
780
|
+
"""The accumulated :class:`RegisteredManifest` of everything recorded
|
|
781
|
+
so far.
|
|
782
|
+
|
|
783
|
+
The host reads this after ``register`` settles to fold the addon's
|
|
784
|
+
contributions into its single registry. Recording-then-returning, not
|
|
785
|
+
mutating-global-state, is the whole point of the surface.
|
|
786
|
+
"""
|
|
787
|
+
...
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
# ---------------------------------------------------------------------------
|
|
791
|
+
# Module loading & discovery
|
|
792
|
+
# ---------------------------------------------------------------------------
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
class ModuleLoader(Protocol):
|
|
796
|
+
"""The injectable seam that turns a source path into an
|
|
797
|
+
:class:`AddonManifest`.
|
|
798
|
+
|
|
799
|
+
The default implementation is importlib-backed (``loader.py``): it
|
|
800
|
+
spec-loads the ``.py`` entry module and accepts a module-level
|
|
801
|
+
``register`` or a ``manifest``/``default`` object exposing one. A test
|
|
802
|
+
injects a fake that returns a scripted manifest with no real import and
|
|
803
|
+
no filesystem — the host code never assumes a concrete loader, only this
|
|
804
|
+
interface.
|
|
805
|
+
"""
|
|
806
|
+
|
|
807
|
+
async def load(self, path: str) -> AddonManifest:
|
|
808
|
+
"""Resolve and load the addon module at ``path``, returning its
|
|
809
|
+
:class:`AddonManifest`.
|
|
810
|
+
|
|
811
|
+
Implementations isolate import failures and surface them as a raised
|
|
812
|
+
exception (the host converts the raise into an :class:`AddonFault`).
|
|
813
|
+
|
|
814
|
+
:param path: absolute path to the addon's entry module
|
|
815
|
+
"""
|
|
816
|
+
...
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
820
|
+
class AddonDiscovery:
|
|
821
|
+
"""Configuration for discovering addon source modules.
|
|
822
|
+
|
|
823
|
+
Discovery walks a set of roots — by default the per-workspace
|
|
824
|
+
``.indus/addons`` directory — plus any explicitly configured paths,
|
|
825
|
+
yielding the entry modules the :class:`ModuleLoader` will load. The
|
|
826
|
+
directory name and fields are this app's own vocabulary.
|
|
827
|
+
"""
|
|
828
|
+
|
|
829
|
+
# The workspace root whose `.indus/addons` directory is scanned. When
|
|
830
|
+
# None, only `explicit_paths` are used.
|
|
831
|
+
workspace: str | None = None
|
|
832
|
+
# The directory (relative to `workspace`) addons are discovered in.
|
|
833
|
+
# Defaults to ADDONS_DIR.
|
|
834
|
+
dir: str | None = None
|
|
835
|
+
# Additional absolute paths to addon entry modules, loaded as-is.
|
|
836
|
+
explicit_paths: Sequence[str] | None = None
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
#: The default per-workspace directory addons are discovered in, relative to
|
|
840
|
+
#: the workspace root. This app's own vocabulary — kept verbatim from the TS
|
|
841
|
+
#: lineage (``.indus``, *not* ``.indusagi``/``.pindusagi``).
|
|
842
|
+
ADDONS_DIR = ".indus/addons"
|
|
843
|
+
|
|
844
|
+
# Port note: the TS ``ADDON_MANIFEST_FIELD`` (``"indusAddon"`` in a package
|
|
845
|
+
# directory's package.json) is DROPPED with the Node packaging machinery. The
|
|
846
|
+
# locked Python convention (plan §3) recognises exactly two shapes under the
|
|
847
|
+
# addons dir: bare ``*.py`` files and directories holding an ``__init__.py``.
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
851
|
+
class AddonSource:
|
|
852
|
+
"""One discovered addon source the loader will resolve.
|
|
853
|
+
|
|
854
|
+
Produced by discovery and consumed by the :class:`ModuleLoader`: the
|
|
855
|
+
absolute entry ``path`` and a stable :data:`AddonId` derived for it (from
|
|
856
|
+
the declared id or the path). Distinct from the loaded
|
|
857
|
+
:class:`AddonManifest` — a source is "where", a manifest is "what".
|
|
858
|
+
"""
|
|
859
|
+
|
|
860
|
+
# Stable id assigned to this source for attribution and de-duplication.
|
|
861
|
+
id: AddonId
|
|
862
|
+
# Absolute path to the addon's entry module.
|
|
863
|
+
path: str
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
# ---------------------------------------------------------------------------
|
|
867
|
+
# Faults
|
|
868
|
+
# ---------------------------------------------------------------------------
|
|
869
|
+
|
|
870
|
+
#: The closed set of failure categories the addon system can surface.
|
|
871
|
+
#:
|
|
872
|
+
#: - ``load`` — resolving/importing an addon module failed.
|
|
873
|
+
#: - ``register`` — an addon's ``register`` entry point raised.
|
|
874
|
+
#: - ``handler`` — an event handler or interceptor stage raised at runtime.
|
|
875
|
+
#: - ``command`` — a slash command handler raised.
|
|
876
|
+
#: - ``conflict`` — two addons claimed the same command name (or tool id).
|
|
877
|
+
AddonFaultKind: TypeAlias = Literal["load", "register", "handler", "command", "conflict"]
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
881
|
+
class AddonFault:
|
|
882
|
+
"""A typed, discriminated failure raised by the addon system.
|
|
883
|
+
|
|
884
|
+
``kind`` selects the category; ``message`` is a human-readable summary;
|
|
885
|
+
``addon`` (when known) attributes it to the originating addon; the
|
|
886
|
+
optional ``cause`` carries the underlying error for logging without
|
|
887
|
+
forcing consumers to parse the message. Construct one with
|
|
888
|
+
:func:`addon_fault`.
|
|
889
|
+
|
|
890
|
+
Port note: faults are *routed to listeners*, never raised, so the shape
|
|
891
|
+
stays a frozen record (not an ``Exception`` subclass) exactly as in TS.
|
|
892
|
+
"""
|
|
893
|
+
|
|
894
|
+
# Failure category — the discriminant consumers switch on.
|
|
895
|
+
kind: AddonFaultKind
|
|
896
|
+
# Human-readable, single-line summary of what went wrong.
|
|
897
|
+
message: str
|
|
898
|
+
# The addon the fault is attributed to, when known.
|
|
899
|
+
addon: AddonId | None = None
|
|
900
|
+
# Underlying error or structured detail, if any.
|
|
901
|
+
cause: object | None = None
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
#: A subscriber for :class:`AddonFault` reports routed off the dispatcher/host.
|
|
905
|
+
AddonFaultListener: TypeAlias = Callable[[AddonFault], None]
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def addon_fault(
|
|
909
|
+
kind: AddonFaultKind,
|
|
910
|
+
message: str,
|
|
911
|
+
*,
|
|
912
|
+
addon: AddonId | None = None,
|
|
913
|
+
cause: object | None = None,
|
|
914
|
+
) -> AddonFault:
|
|
915
|
+
"""Construct an :class:`AddonFault`. The single sanctioned minter, so the
|
|
916
|
+
shape stays uniform across every producer.
|
|
917
|
+
|
|
918
|
+
:param kind: the failure category
|
|
919
|
+
:param message: a human-readable, single-line summary
|
|
920
|
+
:param addon: optional originating-addon provenance
|
|
921
|
+
:param cause: optional underlying error or structured detail
|
|
922
|
+
"""
|
|
923
|
+
return AddonFault(kind=kind, message=message, addon=addon, cause=cause)
|