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,585 @@
1
+ """Channels contract — the FROZEN type surface of the non-interactive drivers
2
+ (port of TS ``src/channels/contract.ts``).
3
+
4
+ A *channel* is a way to talk to a
5
+ :class:`~induscode.conductor.contract.SessionConductor` from outside the
6
+ interactive terminal. Two channels share this contract:
7
+
8
+ - the **oneshot** channel runs a single request to settlement and writes the
9
+ result to a stream (clean text, or a streamed NDJSON event log);
10
+ - the **link** channel is a long-lived, bidirectional JSON-RPC 2.0 server plus
11
+ a typed driver (client) that drives a child process over NDJSON.
12
+
13
+ This module declares *only* shapes and a handful of inert, pure helpers — no
14
+ I/O, no process plumbing, no dispatch. The server, the driver, the framer, and
15
+ the oneshot runner are each written against the names declared here, so the
16
+ file is intentionally small, append-mostly, and stable.
17
+
18
+ Design stance (ported):
19
+
20
+ - The protocol is **one declarative operation registry**, not a hand-written
21
+ dispatch ladder mirrored by hand-written client methods. An :class:`Op`
22
+ pairs a wire ``method`` name with a typed ``handle``; an :data:`OpRegistry`
23
+ (the result of :func:`define_ops`) is consumed by *both* the server
24
+ (data-driven dispatch) and the link driver (a generated client whose method
25
+ set is derived from the same registry).
26
+ - The wire envelope is **JSON-RPC 2.0**: a request carries
27
+ ``{jsonrpc, id?, method, params?}``, a reply carries ``{jsonrpc, id,
28
+ result}`` or ``{jsonrpc, id, error}``. Decoded frames are plain mappings
29
+ (what :func:`json.loads` yields); :func:`is_reply_ok` discriminates the
30
+ reply arms and :data:`OP_ERROR` pins the error codes.
31
+ - Framing is **NDJSON**, one JSON value per line. The framer (the
32
+ ``encode_line`` / ``decode_lines`` pair bundled as :class:`NdjsonFramer`)
33
+ is correct by construction: it escapes the two line separators (U+2028,
34
+ U+2029) that are valid inside a JSON string but break a naive line
35
+ splitter, and it pulls lines with an async generator rather than an event
36
+ callback.
37
+ - The transport is **injectable**. A :class:`ChannelContext` carries the
38
+ conductor plus the streams and the dialog primitives, so tests drive the
39
+ channels over in-memory pipes with no real stdio.
40
+
41
+ Port notes
42
+ ----------
43
+ - TS wire-frame interfaces (``OpRequest`` / ``ReplyOk`` / ``ReplyErr``) were
44
+ structural views over plain parsed objects; in Python the decoded frames
45
+ *are* plain dicts, so the envelope is documented here (and pinned by
46
+ :data:`OP_ERROR` / :data:`PROTOCOL_VERSION` / :func:`is_reply_ok`) while the
47
+ consumer-facing frames that cross Python API boundaries (:class:`Ask`,
48
+ :class:`AskAnswer`, :class:`Tell`, :class:`Signal`, :class:`OpError`) stay
49
+ typed frozen dataclasses.
50
+ - Wire field names/casing are kept **verbatim** from TS (``jsonrpc``,
51
+ ``method``, ``params``, ``result``, ``error``, ``sessionId``,
52
+ ``autoCondense``, ``messageCount``, ``queuedCount``, …) for cross-host
53
+ compatibility — a TS driver must be able to speak to a Python server and
54
+ vice versa. :data:`LINK_SNAPSHOT_FIELDS` pins the snapshot vocabulary.
55
+ - ``signal_to_wire`` is a Python-only helper: TS ``JSON.stringify`` serialized
56
+ a :data:`SessionSignal` verbatim, but the Python signal dataclasses carry
57
+ their ``kind`` tag as a ``ClassVar`` which the one app-wide codec
58
+ (:func:`~induscode.conductor.serialize.message_to_dict`, plan rule 2) does
59
+ not emit — this helper re-attaches it. No second serializer is introduced.
60
+ - Timings stay in **milliseconds** with the TS camelCase field names; asyncio
61
+ call sites divide by 1000.
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ from collections.abc import AsyncIterable, Awaitable, Callable, Mapping
67
+ from dataclasses import dataclass, replace
68
+ from types import MappingProxyType
69
+ from typing import Any, ClassVar, Final, Literal, Protocol, TypeAlias, TypeVar
70
+
71
+ from induscode.conductor.contract import (
72
+ ConductorState,
73
+ SessionConductor,
74
+ SessionSignal,
75
+ ThinkingLevel,
76
+ Usage,
77
+ )
78
+ from induscode.conductor.serialize import message_to_dict
79
+
80
+ __all__ = [
81
+ "Ask",
82
+ "AskAnswer",
83
+ "ChannelContext",
84
+ "ChannelTimings",
85
+ "ConductorState",
86
+ "DEFAULT_CHANNEL_TIMINGS",
87
+ "DialogBridge",
88
+ "LINK_SNAPSHOT_FIELDS",
89
+ "LinkSnapshot",
90
+ "NdjsonFramer",
91
+ "OP_ERROR",
92
+ "OneshotRequest",
93
+ "OneshotShape",
94
+ "OneshotStrategy",
95
+ "Op",
96
+ "OpError",
97
+ "OpRegistry",
98
+ "PROTOCOL_VERSION",
99
+ "REQUEST_ID_PREFIX",
100
+ "ReadableChunks",
101
+ "RequestId",
102
+ "SessionConductor",
103
+ "Signal",
104
+ "Tell",
105
+ "ThinkingLevel",
106
+ "Usage",
107
+ "WritableLine",
108
+ "define_ops",
109
+ "is_reply_ok",
110
+ "mint_wire_id",
111
+ "resolve_timings",
112
+ "signal_to_wire",
113
+ ]
114
+
115
+ _T = TypeVar("_T")
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # JSON-RPC 2.0 envelope
120
+ # ---------------------------------------------------------------------------
121
+
122
+ #: The protocol version literal stamped on every framed envelope.
123
+ PROTOCOL_VERSION: Final[str] = "2.0"
124
+
125
+ #: The id correlating a request to its reply.
126
+ #:
127
+ #: A request with an id expects exactly one reply bearing the same id; a
128
+ #: request without an id is a notification and is never replied to. Numeric
129
+ #: ids are accepted on the wire but the driver mints string ids.
130
+ RequestId: TypeAlias = str | int
131
+
132
+
133
+ @dataclass(frozen=True, slots=True)
134
+ class OpError:
135
+ """A typed error returned by a failed operation.
136
+
137
+ The ``code`` is a small integer category (negative values follow the
138
+ JSON-RPC reserved ranges); ``message`` is a single-line human summary; the
139
+ optional ``data`` carries structured detail for logging without parsing
140
+ the message. On the wire this frames as ``{"code", "message", "data"?}``.
141
+ """
142
+
143
+ #: Numeric error category.
144
+ code: int
145
+ #: Human-readable, single-line summary.
146
+ message: str
147
+ #: Optional structured detail.
148
+ data: Any = None
149
+
150
+
151
+ def is_reply_ok(reply: Mapping[str, Any]) -> bool:
152
+ """Whether a decoded reply frame is the success arm (``result`` present).
153
+
154
+ The reply envelope is either ``{jsonrpc, id, result}`` or ``{jsonrpc, id,
155
+ error}``; this discriminates by presence of the ``result`` key — the same
156
+ rule the TS ``isReplyOk`` guard applied.
157
+ """
158
+ return "result" in reply
159
+
160
+
161
+ #: The closed set of error codes the channels mint (JSON-RPC reserved values,
162
+ #: exact — cross-cutting plan rule 8).
163
+ OP_ERROR: Final[Mapping[str, int]] = MappingProxyType(
164
+ {
165
+ # The framed line was not valid JSON.
166
+ "parse": -32700,
167
+ # The frame was not a well-formed request.
168
+ "invalidRequest": -32600,
169
+ # No operation is registered under the requested `method`.
170
+ "unknownOp": -32601,
171
+ # The `params` failed the operation schema.
172
+ "invalidParams": -32602,
173
+ # The operation handler threw.
174
+ "handlerFailed": -32000,
175
+ }
176
+ )
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Streams & transport
181
+ # ---------------------------------------------------------------------------
182
+
183
+
184
+ class WritableLine(Protocol):
185
+ """The minimal writable surface a channel emits onto.
186
+
187
+ Pinning the dependency to this one method (rather than an asyncio stream
188
+ writer) is what lets a test capture output into a list while production
189
+ passes a real stdout adapter. (The TS optional drain callback is dropped —
190
+ asyncio writers expose no such hook; flushing is the embedder's concern.)
191
+ """
192
+
193
+ def write(self, chunk: str) -> object:
194
+ """Write one already-framed chunk."""
195
+ ...
196
+
197
+
198
+ #: The minimal readable surface a channel consumes: an async-iterable of
199
+ #: string or byte chunks — exactly what a stdio reader and an in-memory pipe
200
+ #: both satisfy. The framer turns this chunk stream into a line stream;
201
+ #: nothing else reads it directly.
202
+ ReadableChunks: TypeAlias = AsyncIterable[str | bytes]
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # NDJSON framer
207
+ # ---------------------------------------------------------------------------
208
+
209
+
210
+ @dataclass(frozen=True, slots=True)
211
+ class NdjsonFramer:
212
+ """The NDJSON framer pair: the two halves of the line transport.
213
+
214
+ Bundled so the server, the driver, and the oneshot channel all share one
215
+ correct implementation rather than re-deriving framing at each call site.
216
+ The concrete pair lives in :mod:`induscode.channels.framer`; an embedder
217
+ may inject an alternate framer through the driver / server options.
218
+ """
219
+
220
+ #: Serialize one value to a newline-terminated, separator-safe line.
221
+ encode_line: Callable[[Any], str]
222
+ #: Pull parsed values from a chunk stream, one per ``\n`` (an async
223
+ #: generator function over a :data:`ReadableChunks`).
224
+ decode_lines: Callable[[ReadableChunks], Any]
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # Dialog primitives (ask / tell)
229
+ # ---------------------------------------------------------------------------
230
+
231
+
232
+ @dataclass(frozen=True, slots=True)
233
+ class Ask:
234
+ """A blocking request from the agent to whoever is driving the channel.
235
+
236
+ ``ask`` is the round-trip dialog primitive: the server emits an ``ask``
237
+ frame and suspends until a matching answer arrives (or the deadline
238
+ lapses). The ``kind`` names the interaction (a choice, a confirm, a
239
+ free-text prompt, an editor session); ``payload`` carries the
240
+ kind-specific options; the ``id`` correlates the eventual answer. Wire
241
+ form: ``{"type": "ask", "id", "kind", "payload"?}``.
242
+ """
243
+
244
+ #: Frame discriminant on the wire.
245
+ type: ClassVar[Literal["ask"]] = "ask"
246
+ #: Correlation id for the answer.
247
+ id: str
248
+ #: The interaction kind (e.g. ``"select"``, ``"confirm"``, ``"input"``).
249
+ kind: str
250
+ #: Kind-specific options for the interaction.
251
+ payload: Any = None
252
+
253
+
254
+ @dataclass(frozen=True, slots=True)
255
+ class AskAnswer:
256
+ """The answer to an :class:`Ask`, correlated by its ``id``. Wire form:
257
+ ``{"type": "answer", "id", "value"}``."""
258
+
259
+ #: Frame discriminant on the wire.
260
+ type: ClassVar[Literal["answer"]] = "answer"
261
+ #: The id of the :class:`Ask` this answers.
262
+ id: str
263
+ #: The supplied value (kind-specific; ``None`` when dismissed).
264
+ value: Any = None
265
+
266
+
267
+ @dataclass(frozen=True, slots=True)
268
+ class Tell:
269
+ """A one-way notice from the agent to the driver — no answer expected.
270
+
271
+ ``tell`` is the fire-and-forget dialog primitive: status updates, a
272
+ flashed notification, a title change. Wire form: ``{"type": "tell",
273
+ "kind", "payload"?}``.
274
+ """
275
+
276
+ #: Frame discriminant on the wire.
277
+ type: ClassVar[Literal["tell"]] = "tell"
278
+ #: The notice kind (e.g. ``"notify"``, ``"status"``, ``"title"``).
279
+ kind: str
280
+ #: Kind-specific detail.
281
+ payload: Any = None
282
+
283
+
284
+ class DialogBridge(Protocol):
285
+ """The dialog seam a channel exposes to the agent/extension layer.
286
+
287
+ One generic round-trip primitive (:meth:`ask`) plus one fire-and-forget
288
+ primitive (:meth:`tell`); every concrete dialog method (select, confirm,
289
+ input, notify, set-status, …) is expressed in terms of these two, so there
290
+ is no per-method choreography to repeat.
291
+ """
292
+
293
+ async def ask(self, kind: str, payload: Any, fallback: _T) -> _T:
294
+ """Emit an :class:`Ask` and resolve with the matching answer value.
295
+
296
+ Resolves with ``fallback`` if no answer arrives before the deadline,
297
+ so a disconnected or non-interactive driver never wedges the agent.
298
+
299
+ :param kind: the interaction kind
300
+ :param payload: kind-specific options
301
+ :param fallback: value to resolve with on timeout / dismissal
302
+ """
303
+ ...
304
+
305
+ def tell(self, kind: str, payload: Any = None) -> None:
306
+ """Emit a :class:`Tell` and return immediately.
307
+
308
+ :param kind: the notice kind
309
+ :param payload: kind-specific detail
310
+ """
311
+ ...
312
+
313
+
314
+ # ---------------------------------------------------------------------------
315
+ # Channel context
316
+ # ---------------------------------------------------------------------------
317
+
318
+
319
+ @dataclass(frozen=True, slots=True)
320
+ class ChannelContext:
321
+ """The execution context handed to every :attr:`Op.handle` and shared by
322
+ both channels.
323
+
324
+ It bundles the :class:`SessionConductor` an operation delegates to, the
325
+ framed transport (``out`` to emit, ``framer`` to encode), and the
326
+ :class:`DialogBridge` for blocking/fire-and-forget interaction. Everything
327
+ is injected, so a test supplies a fake conductor, a list-backed
328
+ :class:`WritableLine`, and an in-memory dialog bridge with no real process
329
+ attached.
330
+ """
331
+
332
+ #: The session this channel drives; every op delegates to it.
333
+ conductor: SessionConductor
334
+ #: The framed output sink for replies, signals, and dialog frames.
335
+ out: WritableLine
336
+ #: The shared NDJSON framer.
337
+ framer: NdjsonFramer
338
+ #: The dialog round-trip / notice bridge.
339
+ dialog: DialogBridge
340
+
341
+
342
+ # ---------------------------------------------------------------------------
343
+ # Operation registry
344
+ # ---------------------------------------------------------------------------
345
+
346
+
347
+ @dataclass(frozen=True, slots=True)
348
+ class Op:
349
+ """A single named operation in the link protocol.
350
+
351
+ An op binds a wire ``method`` name to a typed ``handle``: given the
352
+ request ``params`` (the decoded wire payload — ``None`` when the op takes
353
+ none) and the :class:`ChannelContext`, it produces a result. The server
354
+ invokes ``handle`` on a matching request and frames its resolved value
355
+ into a reply; the link driver exposes a method of the same name that
356
+ round-trips params to result. One declaration drives both halves.
357
+ """
358
+
359
+ #: The wire method name; the registry key and the driver method name.
360
+ method: str
361
+ #: Run the operation against the live session.
362
+ handle: Callable[[Any, ChannelContext], Awaitable[Any]]
363
+
364
+
365
+ #: A map of operation name to its :class:`Op` declaration — what
366
+ #: :func:`define_ops` produces and what both the server and the link driver
367
+ #: consume. Keyed by the same string used as each op's ``method``, so dispatch
368
+ #: is a single lookup and the driver's method set is exactly the key set.
369
+ OpRegistry: TypeAlias = Mapping[str, Op]
370
+
371
+
372
+ def define_ops(ops: Mapping[str, Op]) -> OpRegistry:
373
+ """Freeze a set of operation declarations into an :data:`OpRegistry`.
374
+
375
+ The lone sanctioned way to mint a registry, so the dispatch map and the
376
+ driver method set always derive from one frozen source. Each entry's key
377
+ is the wire method name; the value is the :class:`Op`. The result is a
378
+ read-only mapping so neither half can mutate the protocol at runtime.
379
+
380
+ :param ops: a record of method-name → :class:`Op`
381
+ :returns: the frozen registry
382
+ """
383
+ return MappingProxyType(dict(ops))
384
+
385
+
386
+ # ---------------------------------------------------------------------------
387
+ # Session-state projection
388
+ # ---------------------------------------------------------------------------
389
+
390
+ #: The session-state projection sent over the link.
391
+ #:
392
+ #: A flat, serializable snapshot of everything a driver needs to mirror the
393
+ #: session without holding a live conductor: which model is bound, the
394
+ #: reasoning effort, the busy flags, the persisted location, and the queue
395
+ #: depth. It is the link's own vocabulary — derived from the conductor's
396
+ #: :class:`ConductorState` but shaped for the wire, not a passthrough of the
397
+ #: internal state object. On the wire (and in Python) it is a plain dict whose
398
+ #: keys are exactly :data:`LINK_SNAPSHOT_FIELDS`, produced by
399
+ #: :func:`induscode.channels.session_ops.project_snapshot`.
400
+ LinkSnapshot: TypeAlias = dict[str, Any]
401
+
402
+ #: The verbatim TS wire field names of a :data:`LinkSnapshot`, pinned for
403
+ #: cross-host compatibility (``sessionFile`` is optional and absent when the
404
+ #: session is not persisted).
405
+ LINK_SNAPSHOT_FIELDS: Final[tuple[str, ...]] = (
406
+ "model",
407
+ "thinking",
408
+ "streaming",
409
+ "condensing",
410
+ "faulted",
411
+ "sessionId",
412
+ "sessionFile",
413
+ "autoCondense",
414
+ "messageCount",
415
+ "queuedCount",
416
+ "usage",
417
+ )
418
+
419
+
420
+ # ---------------------------------------------------------------------------
421
+ # Signals
422
+ # ---------------------------------------------------------------------------
423
+
424
+
425
+ @dataclass(frozen=True, slots=True)
426
+ class Signal:
427
+ """An uncorrelated event frame streamed from server to driver.
428
+
429
+ Distinct from a reply (which answers a specific request): a signal is
430
+ pushed as the turn progresses and carries no ``id``. The driver fans
431
+ signals out to its listener; the oneshot NDJSON shape writes them straight
432
+ to the sink. Wire form: ``{"type": "signal", "name", "body"}``.
433
+ """
434
+
435
+ #: Frame discriminant on the wire.
436
+ type: ClassVar[Literal["signal"]] = "signal"
437
+ #: The signal name (the conductor signal kind, projected to the wire).
438
+ name: str
439
+ #: The signal's payload.
440
+ body: Any = None
441
+
442
+
443
+ def signal_to_wire(signal: SessionSignal) -> dict[str, Any]:
444
+ """Project a conductor :data:`SessionSignal` to its wire dict.
445
+
446
+ TS serialized the signal object verbatim (its ``kind`` is a plain
447
+ property); the Python signal dataclasses carry ``kind`` as a ``ClassVar``
448
+ which the one app-wide codec
449
+ (:func:`~induscode.conductor.serialize.message_to_dict`) does not emit, so
450
+ this helper re-attaches the discriminant. The field payload still flows
451
+ through that codec — no second serializer (plan rule 2).
452
+ """
453
+ body = message_to_dict(signal)
454
+ wire: dict[str, Any] = {"kind": signal.kind}
455
+ if isinstance(body, Mapping):
456
+ for key, value in body.items():
457
+ wire[key] = value
458
+ return wire
459
+
460
+
461
+ # ---------------------------------------------------------------------------
462
+ # Oneshot channel
463
+ # ---------------------------------------------------------------------------
464
+
465
+ #: The two output shapes the oneshot channel can produce.
466
+ OneshotShape: TypeAlias = Literal["text", "ndjson"]
467
+
468
+
469
+ @dataclass(frozen=True, slots=True)
470
+ class OneshotRequest:
471
+ """The request the oneshot channel runs.
472
+
473
+ One or more prompts run sequentially to settlement; ``images`` ride along
474
+ with the first. ``shape`` selects between clean final text and a streamed
475
+ NDJSON event log.
476
+ """
477
+
478
+ #: Output shape: clean final text, or a streamed NDJSON event log.
479
+ shape: OneshotShape
480
+ #: The prompts to run, in order.
481
+ prompts: tuple[str, ...]
482
+ #: Images attached to the first prompt, if any (framework
483
+ #: ``indusagi.ai.ImageContent`` values).
484
+ images: tuple[Any, ...] | None = None
485
+
486
+
487
+ @dataclass(frozen=True, slots=True)
488
+ class OneshotStrategy:
489
+ """A pluggable per-shape strategy for the oneshot channel.
490
+
491
+ Each shape (:data:`OneshotShape`) supplies one of these: ``on_start`` runs
492
+ once before the prompts (e.g. emit a header line for NDJSON),
493
+ ``on_signal`` reacts to each conductor signal (NDJSON emits; text
494
+ ignores), and ``finish`` turns the settled state into the process exit
495
+ code (0 ok, 1 on fault). Selecting a shape is choosing a strategy object —
496
+ there are no shape branches sprinkled through the runner body. ``on_start``
497
+ and ``finish`` may be sync or return an awaitable (the TS
498
+ ``void | Promise<void>`` / ``number | Promise<number>`` latitude).
499
+ """
500
+
501
+ #: Produce the exit code from the settled state once all prompts resolve.
502
+ finish: Callable[[ConductorState, ChannelContext], Any]
503
+ #: Run once before any prompt is submitted.
504
+ on_start: Callable[[ChannelContext], Any] | None = None
505
+ #: React to one streamed signal as the turn progresses.
506
+ on_signal: Callable[[SessionSignal, ChannelContext], None] | None = None
507
+
508
+
509
+ # ---------------------------------------------------------------------------
510
+ # Driver / server configuration
511
+ # ---------------------------------------------------------------------------
512
+
513
+ #: The request-id prefix the driver stamps on outgoing requests.
514
+ REQUEST_ID_PREFIX: Final[str] = "lnk-"
515
+
516
+
517
+ @dataclass(frozen=True, slots=True)
518
+ class ChannelTimings:
519
+ """Timeouts and intervals the channels need, sourced from config rather
520
+ than hard-coded at the call sites.
521
+
522
+ Every duration is in **milliseconds** (field names and units verbatim from
523
+ TS; asyncio call sites divide by 1000). Defaults are supplied by
524
+ :data:`DEFAULT_CHANNEL_TIMINGS`; an embedder may override any of them when
525
+ it constructs a driver or server.
526
+ """
527
+
528
+ #: How long to wait for a spawned child to report ready before failing.
529
+ startupMs: int
530
+ #: Grace period after a soft terminate before a hard kill.
531
+ shutdownMs: int
532
+ #: How long a sent request waits for its reply before rejecting.
533
+ requestMs: int
534
+ #: How long :meth:`DialogBridge.ask` waits before resolving its fallback.
535
+ dialogMs: int
536
+
537
+
538
+ #: The default :class:`ChannelTimings` — deliberately distinct values, owned
539
+ #: here and overridable, not a copied constant set scattered across runners.
540
+ DEFAULT_CHANNEL_TIMINGS: Final[ChannelTimings] = ChannelTimings(
541
+ startupMs=1_500,
542
+ shutdownMs=1_200,
543
+ requestMs=45_000,
544
+ dialogMs=90_000,
545
+ )
546
+
547
+
548
+ def resolve_timings(overrides: Mapping[str, int] | None = None) -> ChannelTimings:
549
+ """Merge partial timing overrides onto the frozen defaults.
550
+
551
+ (TS kept a private copy of this in both the server and the driver; the
552
+ Python port hosts the one merge here.)
553
+
554
+ :param overrides: any subset of :class:`ChannelTimings` field names
555
+ """
556
+ if overrides is None or len(overrides) == 0:
557
+ return DEFAULT_CHANNEL_TIMINGS
558
+ return replace(DEFAULT_CHANNEL_TIMINGS, **dict(overrides))
559
+
560
+
561
+ # ---------------------------------------------------------------------------
562
+ # Id minting
563
+ # ---------------------------------------------------------------------------
564
+
565
+ _BASE36_DIGITS: Final[str] = "0123456789abcdefghijklmnopqrstuvwxyz"
566
+
567
+
568
+ def _base36(n: int) -> str:
569
+ """Lowercase base-36 rendering of a non-negative int (TS ``toString(36)``)."""
570
+ if n <= 0:
571
+ return "0"
572
+ out: list[str] = []
573
+ while n > 0:
574
+ n, r = divmod(n, 36)
575
+ out.append(_BASE36_DIGITS[r])
576
+ return "".join(reversed(out))
577
+
578
+
579
+ def mint_wire_id(prefix: str, now_ms: int, seq: int) -> str:
580
+ """Mint a process-unique correlation id: ``<prefix><now36>-<seq36>``.
581
+
582
+ The same shape the TS driver (``lnk-…``) and dialog bridge (``ask-…``)
583
+ minted with ``Date.now().toString(36)`` + a sequence counter.
584
+ """
585
+ return f"{prefix}{_base36(now_ms)}-{_base36(seq)}"