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.
Files changed (167) hide show
  1. induscode/__init__.py +56 -0
  2. induscode/addons/__init__.py +176 -0
  3. induscode/addons/contract.py +923 -0
  4. induscode/addons/dispatch/__init__.py +43 -0
  5. induscode/addons/dispatch/event_dispatcher.py +348 -0
  6. induscode/addons/dispatch/tool_interceptor.py +349 -0
  7. induscode/addons/host.py +469 -0
  8. induscode/addons/loader.py +314 -0
  9. induscode/addons/manifest.py +232 -0
  10. induscode/addons/surface.py +199 -0
  11. induscode/boot/__init__.py +108 -0
  12. induscode/boot/auth_vault.py +323 -0
  13. induscode/boot/boot.py +210 -0
  14. induscode/boot/contract.py +223 -0
  15. induscode/boot/invocation.py +117 -0
  16. induscode/boot/runners/__init__.py +42 -0
  17. induscode/boot/runners/link_runner.py +82 -0
  18. induscode/boot/runners/oneshot_runner.py +85 -0
  19. induscode/boot/runners/registry.py +46 -0
  20. induscode/boot/runners/repl_runner.py +340 -0
  21. induscode/boot/runners/session.py +549 -0
  22. induscode/boot/stages.py +198 -0
  23. induscode/boot/upgrade/__init__.py +36 -0
  24. induscode/boot/upgrade/apply.py +125 -0
  25. induscode/boot/upgrade/upgrades.py +136 -0
  26. induscode/briefing/__init__.py +115 -0
  27. induscode/briefing/compose.py +414 -0
  28. induscode/briefing/contract.py +528 -0
  29. induscode/briefing/macros.py +721 -0
  30. induscode/briefing/skills.py +417 -0
  31. induscode/capability_deck/__init__.py +233 -0
  32. induscode/capability_deck/bridge_ledger/__init__.py +66 -0
  33. induscode/capability_deck/bridge_ledger/key.py +181 -0
  34. induscode/capability_deck/bridge_ledger/ledger.py +276 -0
  35. induscode/capability_deck/bridge_ledger/network.py +336 -0
  36. induscode/capability_deck/builtin_bridge.py +358 -0
  37. induscode/capability_deck/cards/__init__.py +116 -0
  38. induscode/capability_deck/cards/bg_process.py +482 -0
  39. induscode/capability_deck/cards/memory.py +226 -0
  40. induscode/capability_deck/cards/saas.py +280 -0
  41. induscode/capability_deck/cards/task.py +256 -0
  42. induscode/capability_deck/cards/todo.py +312 -0
  43. induscode/capability_deck/contract.py +450 -0
  44. induscode/capability_deck/manifest.py +126 -0
  45. induscode/capability_deck/provision.py +217 -0
  46. induscode/channels/__init__.py +146 -0
  47. induscode/channels/contract.py +585 -0
  48. induscode/channels/framer.py +132 -0
  49. induscode/channels/link/__init__.py +50 -0
  50. induscode/channels/link/dialog.py +246 -0
  51. induscode/channels/link/driver.py +308 -0
  52. induscode/channels/link/server.py +217 -0
  53. induscode/channels/oneshot.py +178 -0
  54. induscode/channels/ops.py +140 -0
  55. induscode/channels/session_ops.py +172 -0
  56. induscode/conductor/__init__.py +240 -0
  57. induscode/conductor/catalog.py +309 -0
  58. induscode/conductor/conductor.py +1084 -0
  59. induscode/conductor/contract.py +1035 -0
  60. induscode/conductor/matcher.py +291 -0
  61. induscode/conductor/serialize.py +575 -0
  62. induscode/conductor/signal_hub.py +382 -0
  63. induscode/conductor/skill_parse.py +294 -0
  64. induscode/conductor/transcript_store.py +449 -0
  65. induscode/console/__init__.py +236 -0
  66. induscode/console/app.py +1677 -0
  67. induscode/console/components/__init__.py +62 -0
  68. induscode/console/components/banner.py +499 -0
  69. induscode/console/components/banner_sweep.py +188 -0
  70. induscode/console/components/emblem.py +181 -0
  71. induscode/console/components/status_bar.py +102 -0
  72. induscode/console/contract.py +836 -0
  73. induscode/console/input/__init__.py +107 -0
  74. induscode/console/input/chord.py +197 -0
  75. induscode/console/input/dir_reader.py +113 -0
  76. induscode/console/input/intents.py +258 -0
  77. induscode/console/input/providers.py +469 -0
  78. induscode/console/mount.py +137 -0
  79. induscode/console/overlays/__init__.py +94 -0
  80. induscode/console/overlays/auth.py +503 -0
  81. induscode/console/overlays/pickers.py +526 -0
  82. induscode/console/overlays/router.py +129 -0
  83. induscode/console/overlays/sessions.py +232 -0
  84. induscode/console/reducer.py +145 -0
  85. induscode/console/resume_picker.py +156 -0
  86. induscode/console/slash_commands/__init__.py +78 -0
  87. induscode/console/slash_commands/builtins.py +254 -0
  88. induscode/console/slash_commands/dynamic.py +217 -0
  89. induscode/console/slash_commands/integrations.py +949 -0
  90. induscode/console/slash_commands/transcript.py +404 -0
  91. induscode/console/slash_commands/workbench.py +430 -0
  92. induscode/console/startup.py +434 -0
  93. induscode/console/theme/__init__.py +44 -0
  94. induscode/console/theme/adapter.py +168 -0
  95. induscode/console/theme/palette.py +128 -0
  96. induscode/console/theme/resolve.py +123 -0
  97. induscode/console/theme/tokens.py +185 -0
  98. induscode/console_slash/__init__.py +111 -0
  99. induscode/console_slash/contract.py +185 -0
  100. induscode/console_slash/registry.py +140 -0
  101. induscode/console_slash/resolve.py +194 -0
  102. induscode/console_slash/shared.py +172 -0
  103. induscode/entry.py +108 -0
  104. induscode/insight/__init__.py +153 -0
  105. induscode/insight/collector.py +73 -0
  106. induscode/insight/replay.py +305 -0
  107. induscode/insight/wrapper.py +1115 -0
  108. induscode/kit/__init__.py +82 -0
  109. induscode/kit/clipboard_image.py +215 -0
  110. induscode/kit/external_editor.py +120 -0
  111. induscode/kit/image.py +188 -0
  112. induscode/kit/shell.py +89 -0
  113. induscode/kit/tool_fetch.py +288 -0
  114. induscode/launch/__init__.py +224 -0
  115. induscode/launch/catalog.py +310 -0
  116. induscode/launch/contract.py +569 -0
  117. induscode/launch/credentials.py +852 -0
  118. induscode/launch/invocation/__init__.py +39 -0
  119. induscode/launch/invocation/attachments.py +281 -0
  120. induscode/launch/invocation/flags.py +210 -0
  121. induscode/launch/invocation/read.py +369 -0
  122. induscode/launch/invocation/usage.py +110 -0
  123. induscode/launch/oauth.py +808 -0
  124. induscode/launch/packages.py +299 -0
  125. induscode/launch/pickers.py +291 -0
  126. induscode/py.typed +0 -0
  127. induscode/runtime_bridge/__init__.py +166 -0
  128. induscode/runtime_bridge/bridges/__init__.py +66 -0
  129. induscode/runtime_bridge/bridges/_drive.py +268 -0
  130. induscode/runtime_bridge/bridges/builtins.py +177 -0
  131. induscode/runtime_bridge/bridges/claude_cli.py +198 -0
  132. induscode/runtime_bridge/bridges/codex_cli.py +203 -0
  133. induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
  134. induscode/runtime_bridge/broker.py +397 -0
  135. induscode/runtime_bridge/contract.py +734 -0
  136. induscode/runtime_bridge/sink.py +351 -0
  137. induscode/sessions/__init__.py +25 -0
  138. induscode/sessions/contract.py +119 -0
  139. induscode/sessions/library.py +350 -0
  140. induscode/settings/__init__.py +47 -0
  141. induscode/settings/contract.py +313 -0
  142. induscode/settings/manager.py +268 -0
  143. induscode/transcript_export/__init__.py +109 -0
  144. induscode/transcript_export/contract.py +522 -0
  145. induscode/transcript_export/publish.py +455 -0
  146. induscode/transcript_export/sgr.py +566 -0
  147. induscode/transcript_export/template.py +319 -0
  148. induscode/transcript_export/theme_bridge.py +325 -0
  149. induscode/window_budget/__init__.py +76 -0
  150. induscode/window_budget/budget/__init__.py +26 -0
  151. induscode/window_budget/budget/estimate.py +273 -0
  152. induscode/window_budget/budget/gate.py +60 -0
  153. induscode/window_budget/budget/slice.py +145 -0
  154. induscode/window_budget/condenser.py +170 -0
  155. induscode/window_budget/contract.py +329 -0
  156. induscode/window_budget/summarize/__init__.py +33 -0
  157. induscode/window_budget/summarize/condense.py +212 -0
  158. induscode/window_budget/summarize/prompt.py +241 -0
  159. induscode/workspace/__init__.py +30 -0
  160. induscode/workspace/brand.py +96 -0
  161. induscode/workspace/locator.py +269 -0
  162. induscode-0.1.0.dist-info/METADATA +97 -0
  163. induscode-0.1.0.dist-info/RECORD +167 -0
  164. induscode-0.1.0.dist-info/WHEEL +4 -0
  165. induscode-0.1.0.dist-info/entry_points.txt +3 -0
  166. induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
  167. 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)