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,734 @@
1
+ """Runtime-bridge contract — the FROZEN type surface of provider *routing*
2
+ (port of TS ``src/runtime-bridge/contract.ts`` + the type half of
3
+ ``broker.ts``).
4
+
5
+ This module is the single typed seam that decides, per model, **where a turn
6
+ is actually produced**: by the framework's own network stream
7
+ (:func:`indusagi.ai.stream_simple` over an HTTP provider) or by an *external
8
+ runtime* — a child coding-agent process driven over its own protocol (an
9
+ Anthropic-flavoured CLI speaking line-delimited JSON, an OpenAI-flavoured CLI
10
+ emitting ``--json`` items, or a peer agent reachable over JSON-RPC). The
11
+ product never calls a bridge or the framework stream directly; it asks the
12
+ :class:`RuntimeBroker` to route, and the broker picks the path.
13
+
14
+ Design stance (unchanged from the TS source):
15
+
16
+ - A model is *annotated*, not re-catalogued. The model catalog/matcher own
17
+ the model list; this layer only attaches an optional
18
+ :class:`ExternalRuntimeSpec` that says "this model is backed by a spawned
19
+ CLI / a peer agent, here is how to reach it and authenticate". A model
20
+ with no spec routes normally.
21
+ - Every external runtime speaks a *different* wire dialect, but the broker
22
+ should not care. Each bridge is reduced to a **parser** that yields a
23
+ provider-neutral :data:`NormalizedEvent` stream, and the single
24
+ :class:`BridgeEventSink` translates those events into the framework's
25
+ :class:`~indusagi.ai.AssistantMessageEventStream` push shape. The
26
+ imperative ``push_start/push_text_delta/push_done`` idiom lives in exactly
27
+ one place (:mod:`induscode.runtime_bridge.sink`).
28
+ - The child process / RPC peer a bridge drives is reached through an
29
+ **injectable** :class:`ChildTransport`, never a hard-coded spawn. Tests
30
+ hand the bridge a fake transport so no real ``claude``/``codex`` binary is
31
+ launched.
32
+ - Authentication policy is data, not control flow:
33
+ :meth:`RuntimeBridge.requires_credential` answers "does this model need a
34
+ key on disk before it can be offered?" — a spawned-CLI runtime that owns
35
+ its own auth returns ``False``, letting the model appear available with no
36
+ key.
37
+
38
+ Framework anchors (all from the sibling rebuilt framework, the ``indusagi``
39
+ package) — consumed verbatim, never re-derived: ``Model``, ``Context``,
40
+ ``AssistantMessage``, ``AssistantMessageEventStream``, ``Api``,
41
+ ``StopReason``, ``ToolCall``, ``SimpleStreamOptions``, ``KnownProvider`` from
42
+ :mod:`indusagi.ai`; ``stream_simple`` is the network fallback the broker
43
+ routes to.
44
+
45
+ Interface-dictated shapes preserved as-is: the ``bridge:<adapter>`` synthetic
46
+ endpoint convention for runtime-backed models, and the external CLI protocol
47
+ vocabularies — surfaced only as the opaque payloads a :class:`ChildTransport`
48
+ carries, never re-expressed.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ from collections.abc import Awaitable, Callable, Mapping
54
+ from dataclasses import dataclass, field
55
+ from typing import Any, ClassVar, Final, Literal, Protocol, TypeAlias
56
+
57
+ # Re-exported framework vocabulary routing consumers routinely compose.
58
+ from indusagi.ai import (
59
+ Api,
60
+ AssistantMessage,
61
+ AssistantMessageEventStream,
62
+ Context,
63
+ KnownProvider,
64
+ Model,
65
+ SimpleStreamOptions,
66
+ StopReason,
67
+ ToolCall,
68
+ )
69
+
70
+ __all__ = [
71
+ "Api",
72
+ "AssistantMessage",
73
+ "AssistantMessageEventStream",
74
+ "BridgeEventSink",
75
+ "BridgeFailure",
76
+ "ChildMessage",
77
+ "ChildRequest",
78
+ "ChildTransport",
79
+ "ChildTransportFactory",
80
+ "Context",
81
+ "ExchangeOptions",
82
+ "ExternalRoute",
83
+ "ExternalRuntimeSpec",
84
+ "FailedEvent",
85
+ "FinishEvent",
86
+ "FinishReason",
87
+ "FrameworkRoute",
88
+ "FrameworkStream",
89
+ "KnownProvider",
90
+ "Model",
91
+ "NORMALIZED_EVENT_KINDS",
92
+ "NormalizedEvent",
93
+ "NormalizedEventKind",
94
+ "RUNTIME_ENDPOINT_SCHEME",
95
+ "RUNTIME_LINK_ENTRY",
96
+ "ResumeEvent",
97
+ "RuntimeAdapterId",
98
+ "RuntimeAuthMode",
99
+ "RuntimeBridge",
100
+ "RuntimeBroker",
101
+ "RuntimeEndpointScheme",
102
+ "RuntimeLink",
103
+ "RuntimeLinkEntryTag",
104
+ "RuntimeLinkStore",
105
+ "RuntimeRoute",
106
+ "SimpleStreamOptions",
107
+ "StopReason",
108
+ "TextEvent",
109
+ "ThinkingEvent",
110
+ "ToolCall",
111
+ "ToolCallEvent",
112
+ "TransportContext",
113
+ "runtime_endpoint",
114
+ ]
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # External-runtime annotation
119
+ # ---------------------------------------------------------------------------
120
+
121
+ #: The set of bridge adapters this layer ships. Each value names a concrete
122
+ #: :class:`RuntimeBridge` implementation and the wire dialect it speaks:
123
+ #:
124
+ #: - ``"claude-cli"`` — drives an Anthropic-flavoured CLI emitting
125
+ #: line-delimited stream-json content blocks.
126
+ #: - ``"codex-cli"`` — drives an OpenAI-flavoured CLI emitting ``--json``
127
+ #: turn/item events.
128
+ #: - ``"indusagi-cli"`` — drives a peer agent over JSON-RPC; the child
129
+ #: already speaks the framework event vocabulary, so its events map onto
130
+ #: :data:`NormalizedEvent` near-directly.
131
+ #:
132
+ #: Left open (TS ``(string & {})``) so an extension can register a further
133
+ #: adapter without editing this alias, while the three shipped ids live in
134
+ #: ``bridges.BUILTIN_ADAPTERS``.
135
+ RuntimeAdapterId: TypeAlias = str
136
+
137
+ #: How a model bound to an external runtime authenticates.
138
+ #:
139
+ #: - ``"external-cli"`` — the spawned child owns its own auth (its own login
140
+ #: / keychain); the product needs **no** key on disk, so the model can be
141
+ #: offered as available with an empty vault. This is the policy that makes
142
+ #: a CLI-backed model "just work" once the underlying tool is logged in.
143
+ #: - ``"api-key"`` — the runtime still needs an API key resolved from the
144
+ #: credential vault, same as a normal HTTP provider; only the *transport*
145
+ #: differs.
146
+ RuntimeAuthMode: TypeAlias = Literal["external-cli", "api-key"]
147
+
148
+
149
+ @dataclass(frozen=True, slots=True)
150
+ class ExternalRuntimeSpec:
151
+ """The annotation that turns an ordinary framework ``Model`` into an
152
+ external-runtime model (TS ``ExternalRuntimeSpec``).
153
+
154
+ Attached alongside a catalog card (it is *additive* metadata — the
155
+ catalog/matcher are unaware of it) and consulted by the
156
+ :class:`RuntimeBroker` to decide routing. All transport fields are
157
+ optional: a bridge may have a sensible default binary, take no extra
158
+ args, inherit the parent environment, and route to its own downstream
159
+ source. Only ``adapter`` is required, because it selects which
160
+ :class:`RuntimeBridge` owns the exchange. Field names keep the TS
161
+ spelling.
162
+ """
163
+
164
+ #: Which bridge owns this model's exchange (the :attr:`RuntimeBridge.adapter`).
165
+ adapter: RuntimeAdapterId
166
+ #: Whether the runtime needs a key on disk, or owns its own auth.
167
+ authMode: RuntimeAuthMode
168
+ #: Override for the child executable to launch (defaults to the bridge's own).
169
+ binaryPath: str | None = None
170
+ #: Extra command-line arguments prepended to the bridge's protocol flags.
171
+ args: tuple[str, ...] | None = None
172
+ #: Environment overrides merged into the child process environment.
173
+ env: Mapping[str, str] | None = None
174
+ #: For a bridge that fronts another source (e.g. a peer agent that itself
175
+ #: talks to a downstream provider), the provider slug the child should
176
+ #: target. Ignored by bridges that terminate the exchange themselves.
177
+ delegate: str | None = None
178
+
179
+
180
+ #: The synthetic endpoint scheme stamped onto a runtime-backed model's
181
+ #: ``baseUrl``. A model annotated with :class:`ExternalRuntimeSpec` carries
182
+ #: ``baseUrl == f"{RUNTIME_ENDPOINT_SCHEME}{adapter}"`` so it has a stable,
183
+ #: non-HTTP address that never resolves to a network host.
184
+ RUNTIME_ENDPOINT_SCHEME: Final = "bridge:"
185
+
186
+ #: The literal type of :data:`RUNTIME_ENDPOINT_SCHEME`.
187
+ RuntimeEndpointScheme: TypeAlias = Literal["bridge:"]
188
+
189
+
190
+ def runtime_endpoint(adapter: RuntimeAdapterId) -> str:
191
+ """Compose the synthetic ``bridge:<adapter>`` endpoint for a
192
+ runtime-backed model. Inert string helper; the single sanctioned way to
193
+ mint the convention so it stays uniform across the catalog annotation
194
+ and the broker's routing check.
195
+
196
+ :param adapter: the :attr:`RuntimeBridge.adapter` owning the model
197
+ """
198
+ return f"{RUNTIME_ENDPOINT_SCHEME}{adapter}"
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # Normalized, provider-neutral event
203
+ # ---------------------------------------------------------------------------
204
+ #
205
+ # A provider-neutral streamed event — the common currency between a bridge's
206
+ # per-dialect parser and the BridgeEventSink. Each external runtime emits its
207
+ # own wire vocabulary (Anthropic content blocks, OpenAI items, peer JSON-RPC
208
+ # events). A bridge's only job is to map that vocabulary onto this small,
209
+ # closed union; the sink then translates a NormalizedEvent into the matching
210
+ # framework AssistantMessageEventStream push call. This is the seam that
211
+ # removes the per-bridge `stream.push*` duplication.
212
+
213
+ #: The terminal reasons an external exchange can settle on. A subset of the
214
+ #: framework's ``StopReason`` — the non-error outcomes a bridge reports to
215
+ #: the sink, which then emits the framework ``done`` event.
216
+ FinishReason: TypeAlias = Literal["stop", "length", "toolUse"]
217
+
218
+
219
+ @dataclass(frozen=True, slots=True)
220
+ class BridgeFailure:
221
+ """A typed failure surfaced by a bridge when an external exchange breaks
222
+ (TS ``BridgeFailure``). The ``aborted`` flag distinguishes a
223
+ caller-cancelled exchange (the child was interrupted) from a genuine
224
+ fault, so the sink can emit the matching framework error reason
225
+ (``aborted`` vs ``error``)."""
226
+
227
+ #: Human-readable, single-line summary of what went wrong.
228
+ message: str
229
+ #: True when the exchange was cancelled rather than failing on its own.
230
+ aborted: bool = False
231
+ #: Underlying error or structured detail, if any.
232
+ cause: Any = None
233
+
234
+
235
+ @dataclass(frozen=True, slots=True)
236
+ class TextEvent:
237
+ """A chunk of assistant answer text."""
238
+
239
+ kind: ClassVar[Literal["text"]] = "text"
240
+ delta: str
241
+
242
+
243
+ @dataclass(frozen=True, slots=True)
244
+ class ThinkingEvent:
245
+ """A chunk of reasoning text (runtimes without a thinking channel simply
246
+ never emit this)."""
247
+
248
+ kind: ClassVar[Literal["thinking"]] = "thinking"
249
+ delta: str
250
+
251
+
252
+ @dataclass(frozen=True, slots=True)
253
+ class ToolCallEvent:
254
+ """A fully-formed tool invocation the child decided on."""
255
+
256
+ kind: ClassVar[Literal["tool_call"]] = "tool_call"
257
+ call: ToolCall
258
+
259
+
260
+ @dataclass(frozen=True, slots=True)
261
+ class ResumeEvent:
262
+ """The child reported a session/thread id the bridge can persist to
263
+ reattach the underlying CLI session after a restart. Informational on the
264
+ stream (the sink ignores it); the broker persists it out of band."""
265
+
266
+ kind: ClassVar[Literal["resume"]] = "resume"
267
+ resumeToken: str
268
+
269
+
270
+ @dataclass(frozen=True, slots=True)
271
+ class FinishEvent:
272
+ """The exchange settled successfully with a terminal reason."""
273
+
274
+ kind: ClassVar[Literal["finish"]] = "finish"
275
+ reason: FinishReason
276
+
277
+
278
+ @dataclass(frozen=True, slots=True)
279
+ class FailedEvent:
280
+ """The exchange ended in error."""
281
+
282
+ kind: ClassVar[Literal["failed"]] = "failed"
283
+ error: BridgeFailure
284
+
285
+
286
+ #: The provider-neutral event union (TS ``NormalizedEvent``).
287
+ NormalizedEvent: TypeAlias = (
288
+ TextEvent | ThinkingEvent | ToolCallEvent | ResumeEvent | FinishEvent | FailedEvent
289
+ )
290
+
291
+ #: The discriminant literals of :data:`NormalizedEvent`, for filtering/logging.
292
+ NormalizedEventKind: TypeAlias = Literal[
293
+ "text", "thinking", "tool_call", "resume", "finish", "failed"
294
+ ]
295
+
296
+ #: Every :data:`NormalizedEventKind` value, as a frozen tuple. The sink's
297
+ #: dispatch table and the kind-coverage test both pin against this (the
298
+ #: Python replacement for the TS mapped-type exhaustiveness).
299
+ NORMALIZED_EVENT_KINDS: Final[tuple[NormalizedEventKind, ...]] = (
300
+ "text",
301
+ "thinking",
302
+ "tool_call",
303
+ "resume",
304
+ "finish",
305
+ "failed",
306
+ )
307
+
308
+
309
+ # ---------------------------------------------------------------------------
310
+ # Bridge event sink
311
+ # ---------------------------------------------------------------------------
312
+
313
+
314
+ class BridgeEventSink(Protocol):
315
+ """The single push-stream helper every bridge writes through (TS
316
+ ``BridgeEventSink``).
317
+
318
+ It owns the accumulating ``AssistantMessage``, the content-block index
319
+ bookkeeping, and the lazily-started lifecycle (the first emission opens
320
+ the stream). A bridge never touches ``AssistantMessageEventStream``
321
+ directly; it constructs a sink, drives it with normalized events, and
322
+ returns the sink's :attr:`stream`. This centralizes the
323
+ ``start → push* → finish_success/finish_error`` idiom so the per-bridge
324
+ code is purely a parser. The convenience methods cover the common path;
325
+ :meth:`emit` accepts a raw :data:`NormalizedEvent` for parsers that
326
+ prefer to yield the union directly.
327
+ """
328
+
329
+ def start(self) -> None:
330
+ """Open the underlying stream and push the framework ``start`` event,
331
+ if it has not already started. Idempotent: safe to call before any
332
+ emission, and a no-op once started. The convenience emitters call it
333
+ implicitly."""
334
+ ...
335
+
336
+ def text(self, delta: str) -> None:
337
+ """Append a chunk of assistant answer text (opens a text block on
338
+ first call)."""
339
+ ...
340
+
341
+ def thinking(self, delta: str) -> None:
342
+ """Append a chunk of reasoning text (opens a thinking block on first
343
+ call)."""
344
+ ...
345
+
346
+ def tool_call(self, call: ToolCall) -> None:
347
+ """Emit a fully-formed tool call as its own content block."""
348
+ ...
349
+
350
+ def emit(self, event: NormalizedEvent) -> None:
351
+ """Map one :data:`NormalizedEvent` onto the matching push call. The
352
+ single entry point a parser can drive with the raw union; dispatches
353
+ to :meth:`text` / :meth:`thinking` / :meth:`tool_call` /
354
+ :meth:`finish_success` / :meth:`finish_error` by ``kind``. A
355
+ ``resume`` event is informational and does not touch the stream."""
356
+ ...
357
+
358
+ def finish_success(self, reason: FinishReason = "stop") -> None:
359
+ """Settle the exchange successfully: close any open block, finalize
360
+ the accumulated message, and push the framework ``done`` event with
361
+ ``reason``. Terminal — no further emissions are valid after this.
362
+
363
+ :param reason: the terminal stop reason (defaults to ``"stop"``)
364
+ """
365
+ ...
366
+
367
+ def finish_error(self, error: BridgeFailure) -> None:
368
+ """Settle the exchange in error: push the framework ``error`` event.
369
+ The :attr:`BridgeFailure.aborted` flag selects the framework error
370
+ reason (``aborted`` vs ``error``). Terminal.
371
+
372
+ :param error: the typed bridge failure
373
+ """
374
+ ...
375
+
376
+ @property
377
+ def stream(self) -> AssistantMessageEventStream:
378
+ """The framework push stream the broker hands back to its caller.
379
+ Populated asynchronously as the bridge drives the sink; consumers
380
+ iterate it exactly as they would the stream ``stream_simple``
381
+ returns."""
382
+ ...
383
+
384
+
385
+ # ---------------------------------------------------------------------------
386
+ # Injectable child transport
387
+ # ---------------------------------------------------------------------------
388
+
389
+
390
+ @dataclass(frozen=True, slots=True)
391
+ class ChildMessage:
392
+ """A single message exchanged with the child runtime over its
393
+ :class:`ChildTransport` (TS ``ChildMessage``).
394
+
395
+ The ``payload`` is deliberately opaque: it is whatever the underlying
396
+ protocol carries — a parsed JSON line from a CLI's NDJSON stdout, a
397
+ JSON-RPC notification from a peer agent, a raw text chunk. The bridge
398
+ that owns the transport knows how to interpret it; the contract only
399
+ fixes the envelope so the transport interface itself is dialect-agnostic
400
+ and mockable.
401
+ """
402
+
403
+ #: The protocol payload (a parsed JSON value, a text line, an RPC frame).
404
+ payload: Any
405
+
406
+
407
+ @dataclass(frozen=True, slots=True)
408
+ class ChildRequest:
409
+ """A request sent *to* the child runtime (TS ``ChildRequest``). The
410
+ bridge formats the dialect-specific body; the transport only relays it.
411
+ ``body`` is opaque for the same reason :attr:`ChildMessage.payload` is."""
412
+
413
+ #: The dialect-specific request body the bridge constructed.
414
+ body: Any
415
+
416
+
417
+ class ChildTransport(Protocol):
418
+ """The injectable boundary between a bridge and the actual child process
419
+ / RPC peer it drives (TS ``ChildTransport``).
420
+
421
+ A production transport wraps a spawned process (its stdin/stdout, or a
422
+ JSON-RPC client over that process); a test transport is a hand-written
423
+ fake that records ``send``\\ s and replays canned :class:`ChildMessage`\\ s
424
+ — so a bridge's parser can be exercised with **no real binary launched**.
425
+ The bridge depends only on this interface, never on a subprocess module
426
+ directly.
427
+ """
428
+
429
+ def send(self, request: ChildRequest) -> Awaitable[None]:
430
+ """Relay one :class:`ChildRequest` to the child (e.g. write a prompt
431
+ to stdin or issue a JSON-RPC call). Resolves once the request has
432
+ been handed off.
433
+
434
+ :param request: the dialect-specific request to deliver
435
+ """
436
+ ...
437
+
438
+ def on_message(self, listener: Callable[[ChildMessage], None]) -> Callable[[], None]:
439
+ """Register a listener for inbound :class:`ChildMessage`\\ s from the
440
+ child (stdout lines, RPC notifications). Returns an unsubscribe
441
+ function.
442
+
443
+ :param listener: invoked for each inbound message
444
+ :returns: a disposer that removes the listener
445
+ """
446
+ ...
447
+
448
+ def close(self) -> Awaitable[None]:
449
+ """Terminate the child and release the transport. Idempotent; safe to
450
+ call on an already-closed transport. After ``close``, ``send``
451
+ rejects and no further messages are delivered."""
452
+ ...
453
+
454
+
455
+ # ---------------------------------------------------------------------------
456
+ # Runtime exchange options
457
+ # ---------------------------------------------------------------------------
458
+
459
+
460
+ @dataclass
461
+ class ExchangeOptions(SimpleStreamOptions):
462
+ """Per-exchange options threaded into :meth:`RuntimeBridge.run_exchange`
463
+ (and the framework ``stream_simple`` fallback the broker also drives)
464
+ (TS ``ExchangeOptions``).
465
+
466
+ Extends the framework's :class:`~indusagi.ai.SimpleStreamOptions` (so
467
+ ``signal``, ``apiKey``, ``reasoning``, ``sessionId``, etc. flow straight
468
+ through to the network path) with the routing-layer extras a bridge
469
+ needs:
470
+
471
+ - ``sessionId`` (inherited) correlates the exchange with a persisted
472
+ runtime session so the bridge can resume the underlying CLI session.
473
+ - ``cwd`` is the working directory the child runtime is scoped to
474
+ (defaults to the process cwd).
475
+ - ``resume`` carries a previously-persisted token (e.g. a CLI session id
476
+ / thread id) the bridge reattaches to instead of starting fresh.
477
+ """
478
+
479
+ #: Working directory the child runtime is scoped to.
480
+ cwd: str | None = field(default=None, kw_only=True)
481
+ #: A resume token from a prior exchange to reattach the underlying session.
482
+ resume: str | None = field(default=None, kw_only=True)
483
+
484
+
485
+ # ---------------------------------------------------------------------------
486
+ # Runtime bridge
487
+ # ---------------------------------------------------------------------------
488
+
489
+
490
+ class RuntimeBridge(Protocol):
491
+ """One external-runtime adapter: the strategy that produces a turn for a
492
+ model whose :class:`ExternalRuntimeSpec` names it (TS ``RuntimeBridge``).
493
+
494
+ A bridge is the dialect specialist. Given the bound model, the framework
495
+ context, per-exchange :class:`ExchangeOptions`, and an **injected**
496
+ :class:`ChildTransport`, it drives the child and returns the framework
497
+ push stream the turn streams into. The transport is a parameter (not
498
+ constructed internally) precisely so tests pass a fake and no real CLI
499
+ is spawned. Bridges hold no per-session state on the contract surface;
500
+ live session handles and reuse/persistence are the
501
+ :class:`RuntimeBroker`'s concern.
502
+ """
503
+
504
+ @property
505
+ def adapter(self) -> RuntimeAdapterId:
506
+ """The adapter id this bridge answers to (matched against
507
+ :attr:`ExternalRuntimeSpec.adapter`)."""
508
+ ...
509
+
510
+ def run_exchange(
511
+ self,
512
+ model: Model,
513
+ context: Context,
514
+ opts: ExchangeOptions,
515
+ transport: ChildTransport,
516
+ ) -> AssistantMessageEventStream:
517
+ """Drive one exchange against the external runtime and return the
518
+ framework push stream it streams into. Returns the stream
519
+ **synchronously** (the stream is populated asynchronously as the
520
+ child emits), matching the shape of the framework's own
521
+ ``stream_simple``. The implementation reads inbound
522
+ :class:`ChildMessage`\\ s off ``transport``, parses them into
523
+ :data:`NormalizedEvent`\\ s, and feeds a :class:`BridgeEventSink`.
524
+
525
+ :param model: the bound framework model for this exchange
526
+ :param context: the framework conversation context
527
+ :param opts: per-exchange options (session id, cwd, resume, stream opts)
528
+ :param transport: the injected child boundary to drive
529
+ """
530
+ ...
531
+
532
+ def requires_credential(self, spec: ExternalRuntimeSpec) -> bool:
533
+ """Whether a model bound to this bridge needs a credential on disk
534
+ before it can be offered. Returns ``False`` for an ``"external-cli"``
535
+ spec whose child owns its own auth (so the model is available with an
536
+ empty vault), ``True`` for an ``"api-key"`` spec. The central
537
+ auth-routing predicate.
538
+
539
+ :param spec: the external-runtime annotation to evaluate
540
+ """
541
+ ...
542
+
543
+
544
+ # ---------------------------------------------------------------------------
545
+ # Runtime broker
546
+ # ---------------------------------------------------------------------------
547
+
548
+
549
+ @dataclass(frozen=True, slots=True)
550
+ class ExternalRoute:
551
+ """An external runtime owns the exchange: carries the chosen
552
+ :class:`RuntimeBridge` and the resolved :class:`ExternalRuntimeSpec` the
553
+ broker will drive ``run_exchange`` with."""
554
+
555
+ target: ClassVar[Literal["external"]] = "external"
556
+ bridge: RuntimeBridge
557
+ spec: ExternalRuntimeSpec
558
+
559
+
560
+ @dataclass(frozen=True, slots=True)
561
+ class FrameworkRoute:
562
+ """The turn falls through to the framework network stream
563
+ (``stream_simple``) unchanged."""
564
+
565
+ target: ClassVar[Literal["framework"]] = "framework"
566
+
567
+
568
+ #: The outcome of a routing decision (TS ``RuntimeRoute``).
569
+ RuntimeRoute: TypeAlias = ExternalRoute | FrameworkRoute
570
+
571
+
572
+ class RuntimeBroker(Protocol):
573
+ """The router and registry over :class:`RuntimeBridge`\\ s — the single
574
+ decision point for "external runtime vs framework stream" (TS
575
+ ``RuntimeBroker``).
576
+
577
+ The broker is asked to :meth:`route` every turn. If the model carries an
578
+ :class:`ExternalRuntimeSpec` whose adapter resolves to a registered
579
+ bridge, the broker returns an ``"external"`` route the caller drives via
580
+ the bridge's ``run_exchange``; otherwise it returns a ``"framework"``
581
+ route and the caller runs ``stream_simple``. Bridges are
582
+ :meth:`register`\\ ed at assembly time and looked up by adapter id.
583
+ """
584
+
585
+ def register(self, bridge: RuntimeBridge) -> None:
586
+ """Add a :class:`RuntimeBridge` to the registry, keyed by its
587
+ :attr:`RuntimeBridge.adapter`. Registering an adapter id that already
588
+ exists replaces the prior bridge.
589
+
590
+ :param bridge: the bridge to register
591
+ """
592
+ ...
593
+
594
+ def route(self, model: Model, context: Context, opts: ExchangeOptions) -> RuntimeRoute:
595
+ """Decide how to produce a turn for ``model``. Resolves the model's
596
+ :class:`ExternalRuntimeSpec` (via :meth:`resolve_spec`) and a matching
597
+ registered bridge; returns an ``"external"`` route when both are
598
+ present, else a ``"framework"`` route. The framework
599
+ ``context``/``opts`` are accepted so an implementation may factor
600
+ them into routing, even though the base decision keys on the model.
601
+
602
+ :param model: the model the turn is bound to
603
+ :param context: the framework conversation context for the turn
604
+ :param opts: per-exchange options
605
+ """
606
+ ...
607
+
608
+ def resolve_spec(self, model: Model) -> ExternalRuntimeSpec | None:
609
+ """Resolve the :class:`ExternalRuntimeSpec` annotated onto a model,
610
+ or ``None`` when the model is a plain HTTP-provider model. How the
611
+ spec is attached (a side-table keyed by canonical id, a field on a
612
+ catalog card, a ``bridge:<adapter>`` baseUrl decode) is the
613
+ implementation's choice; the contract only fixes the lookup.
614
+
615
+ :param model: the model to inspect for a runtime annotation
616
+ """
617
+ ...
618
+
619
+ def requires_credential(self, spec: ExternalRuntimeSpec) -> bool:
620
+ """Whether a runtime-annotated model needs a credential on disk
621
+ before it can be offered as available. Delegates to the owning
622
+ bridge's :meth:`RuntimeBridge.requires_credential`; falls back to the
623
+ spec's own ``authMode`` when no bridge is registered — so this
624
+ answers strictly the *runtime* credential question.
625
+
626
+ :param spec: the runtime annotation to evaluate
627
+ """
628
+ ...
629
+
630
+
631
+ # ---------------------------------------------------------------------------
632
+ # Resume-token persistence (type half of TS broker.ts)
633
+ # ---------------------------------------------------------------------------
634
+
635
+ #: The custom transcript-entry tag under which a runtime resume token is
636
+ #: logged. The record this build writes is its own ``"external-runtime-link"``
637
+ #: shape so the persisted log carries no inherited vocabulary. A consumer
638
+ #: scanning the active branch backwards for a reattachable session matches on
639
+ #: this tag.
640
+ RUNTIME_LINK_ENTRY: Final = "external-runtime-link"
641
+
642
+ #: The literal type of :data:`RUNTIME_LINK_ENTRY`.
643
+ RuntimeLinkEntryTag: TypeAlias = Literal["external-runtime-link"]
644
+
645
+
646
+ @dataclass(frozen=True, slots=True)
647
+ class RuntimeLink:
648
+ """The serializable payload persisted under :data:`RUNTIME_LINK_ENTRY`
649
+ (TS ``RuntimeLink``).
650
+
651
+ The renamed shape: ``{source, bridge, resumeToken, at}``. ``source`` is
652
+ the model's provider slug, ``bridge`` is the owning
653
+ :attr:`RuntimeBridge.adapter`, ``resumeToken`` is the reattachable CLI
654
+ session id / thread id the child reported, and ``at`` is the ISO instant
655
+ it was captured. Reuse keys on
656
+ :func:`~induscode.runtime_bridge.broker.runtime_source_key`, derived from
657
+ ``source`` + model id + ``bridge``.
658
+ """
659
+
660
+ #: The model's provider slug (its ``source``).
661
+ source: str
662
+ #: The owning bridge adapter id.
663
+ bridge: str
664
+ #: The reattachable session id / thread id reported by the child runtime.
665
+ resumeToken: str
666
+ #: ISO-8601 instant the token was captured.
667
+ at: str
668
+
669
+
670
+ class RuntimeLinkStore(Protocol):
671
+ """Injectable persistence boundary for resume tokens (TS
672
+ ``RuntimeLinkStore``). The conductor's transcript store binds a real
673
+ implementation (appending a :data:`RUNTIME_LINK_ENTRY` custom entry to
674
+ the active branch and scanning it backwards on lookup); tests pass an
675
+ in-memory fake. Both methods *may* be async to match a disk-backed
676
+ transcript — but see the sync-fast-path note on
677
+ :meth:`~induscode.runtime_bridge.broker._Broker.exchange`."""
678
+
679
+ def save(self, link: RuntimeLink) -> Awaitable[None] | None:
680
+ """Persist a captured resume token as a renamed custom transcript
681
+ entry. Called once per ``resume`` event a bridge surfaces during an
682
+ exchange.
683
+
684
+ :param link: the renamed link record to append
685
+ """
686
+ ...
687
+
688
+ def find(self, source_key: str) -> Awaitable[str | None] | str | None:
689
+ """Resolve the most recent reattachable token for a reuse key, or
690
+ ``None`` when the active branch holds no matching
691
+ :data:`RUNTIME_LINK_ENTRY`. The key is
692
+ :func:`~induscode.runtime_bridge.broker.runtime_source_key`.
693
+
694
+ :param source_key: the composite ``source|model|bridge`` reuse key
695
+ """
696
+ ...
697
+
698
+
699
+ # ---------------------------------------------------------------------------
700
+ # Transport factory (type half of TS broker.ts)
701
+ # ---------------------------------------------------------------------------
702
+
703
+
704
+ @dataclass(frozen=True, slots=True)
705
+ class TransportContext:
706
+ """The context handed to a :data:`ChildTransportFactory` when the broker
707
+ needs a transport for an external exchange (TS ``TransportContext``).
708
+ Carries everything a production factory needs to launch + wire the child
709
+ (the resolved spec's binary/args/env, the working directory, a resume
710
+ token to reattach) without the broker itself touching a subprocess
711
+ module."""
712
+
713
+ #: The resolved runtime spec (binary, args, env, delegate) for the child.
714
+ spec: ExternalRuntimeSpec
715
+ #: The bound model the exchange runs for.
716
+ model: Model
717
+ #: The per-exchange options (session id, cwd, resume, stream opts).
718
+ opts: ExchangeOptions
719
+ #: A persisted resume token resolved for this exchange, if any.
720
+ resume: str | None = None
721
+
722
+
723
+ #: Mints a :class:`ChildTransport` for an external exchange (TS
724
+ #: ``ChildTransportFactory``). The single seam the broker reaches the outside
725
+ #: world through: a production factory spawns the process and adapts its
726
+ #: stdio/RPC into the transport; a test factory returns a scripted fake. The
727
+ #: broker calls it lazily, only on an external route.
728
+ ChildTransportFactory: TypeAlias = Callable[[TransportContext], ChildTransport]
729
+
730
+ #: The framework network-stream signature the broker falls through to (TS
731
+ #: ``FrameworkStream``).
732
+ FrameworkStream: TypeAlias = Callable[
733
+ [Model, Context, ExchangeOptions], AssistantMessageEventStream
734
+ ]