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,308 @@
1
+ """Link driver — the generated client over the JSON-RPC link
2
+ (port of TS ``src/channels/link/driver.ts``).
3
+
4
+ The driver is the mirror of the server: it writes framed requests and reads
5
+ framed replies. Crucially it is *generated*, not hand-written — there is no
6
+ per-op client method. The TS ``Proxy`` becomes a ``__getattr__`` trap: any
7
+ attribute access on the client yields a thunk that frames ``{id, method,
8
+ params} -> awaits {id, result | error}``, so the client mirrors whatever op
9
+ registry the server dispatches with a single body. Adding an op to the
10
+ registry adds a callable method to the driver for free.
11
+
12
+ The driver owns the single reader over the inbound stream. Every decoded
13
+ frame is one of: a correlated reply (settles the pending request under its
14
+ id), an uncorrelated signal (fanned out to the ``on_signal`` hook), or an ask
15
+ (handed to the ``on_ask`` answerer that posts an answer back). One reader,
16
+ three routes — no competing consumers of the transport.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import inspect
23
+ import time
24
+ from collections.abc import Mapping
25
+ from dataclasses import dataclass
26
+ from typing import Any, Callable, Coroutine
27
+
28
+ from induscode.channels.contract import (
29
+ PROTOCOL_VERSION,
30
+ REQUEST_ID_PREFIX,
31
+ Ask,
32
+ ChannelTimings,
33
+ NdjsonFramer,
34
+ OpError,
35
+ ReadableChunks,
36
+ RequestId,
37
+ Signal,
38
+ WritableLine,
39
+ mint_wire_id,
40
+ resolve_timings,
41
+ )
42
+ from induscode.channels.framer import ndjson_framer
43
+
44
+ __all__ = [
45
+ "LinkClient",
46
+ "LinkDriverHandle",
47
+ "LinkDriverIo",
48
+ "LinkRequestError",
49
+ "create_link_driver",
50
+ ]
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Transport seams
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class LinkDriverIo:
60
+ """The injectable transport pair the driver writes to and reads from.
61
+
62
+ (TS named the inbound field ``in``; that is a Python keyword, hence
63
+ ``in_``.)
64
+ """
65
+
66
+ #: Sink the driver writes framed requests to (the child stdin in production).
67
+ out: WritableLine
68
+ #: Source the driver reads framed replies / signals from (the child stdout).
69
+ in_: ReadableChunks
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class _PendingRequest:
74
+ """A pending request awaiting its correlated reply."""
75
+
76
+ future: asyncio.Future[Any]
77
+ timer: asyncio.TimerHandle
78
+
79
+
80
+ class LinkRequestError(Exception):
81
+ """A failed reply turned into a raisable error that carries the wire code."""
82
+
83
+ def __init__(self, error: OpError) -> None:
84
+ super().__init__(error.message)
85
+ #: The JSON-RPC error code from the :class:`OpError`.
86
+ self.code: int = error.code
87
+ #: The structured detail, when the server supplied any.
88
+ self.data: Any = error.data
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Frame classification
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ def _is_reply_frame(frame: Any) -> bool:
97
+ """Whether a decoded frame is a correlated reply."""
98
+ return (
99
+ isinstance(frame, Mapping)
100
+ and "id" in frame
101
+ and ("result" in frame or "error" in frame)
102
+ )
103
+
104
+
105
+ def _is_signal_frame(frame: Any) -> bool:
106
+ """Whether a decoded frame is an uncorrelated signal."""
107
+ return isinstance(frame, Mapping) and frame.get("type") == "signal"
108
+
109
+
110
+ def _is_ask_frame(frame: Any) -> bool:
111
+ """Whether a decoded frame is a server-initiated ask."""
112
+ return (
113
+ isinstance(frame, Mapping)
114
+ and frame.get("type") == "ask"
115
+ and isinstance(frame.get("id"), str)
116
+ )
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Generated client
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ class LinkClient:
125
+ """The generated client: every attribute access is a request thunk.
126
+
127
+ The TS ``Proxy`` trap becomes ``__getattr__``: reading attribute ``m``
128
+ yields a thunk ``(params=None) -> awaitable result`` that round-trips the
129
+ method named ``m`` through the link. No method is hand-written; the op
130
+ registry on the server side is the single source of the callable surface
131
+ (an unregistered name simply rejects with the JSON-RPC ``unknownOp``
132
+ error, exactly as the TS proxy did).
133
+ """
134
+
135
+ __slots__ = ("_request",)
136
+
137
+ def __init__(
138
+ self, request: Callable[[str, Any], Coroutine[Any, Any, Any]]
139
+ ) -> None:
140
+ object.__setattr__(self, "_request", request)
141
+
142
+ def __getattr__(self, name: str) -> Callable[..., Coroutine[Any, Any, Any]]:
143
+ if name.startswith("_"):
144
+ raise AttributeError(name)
145
+ request = self._request
146
+
147
+ def thunk(params: Any = None) -> Coroutine[Any, Any, Any]:
148
+ return request(name, params)
149
+
150
+ thunk.__name__ = name
151
+ return thunk
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Driver handle
156
+ # ---------------------------------------------------------------------------
157
+
158
+
159
+ class LinkDriverHandle:
160
+ """A live driver: the generated client plus its lifecycle.
161
+
162
+ ``client`` is the ``__getattr__``-generated :class:`LinkClient`; ``done``
163
+ resolves when the inbound stream ends; :meth:`close` rejects every
164
+ still-pending request and stops accepting replies.
165
+ """
166
+
167
+ __slots__ = ("client", "done", "_close")
168
+
169
+ def __init__(
170
+ self,
171
+ client: LinkClient,
172
+ done: asyncio.Task[None],
173
+ close: Callable[[str], None],
174
+ ) -> None:
175
+ #: The generated, registry-mirroring client.
176
+ self.client = client
177
+ #: Resolves when the reader loop drains (the inbound stream ended).
178
+ self.done = done
179
+ self._close = close
180
+
181
+ def close(self, reason: str = "link driver closed by caller") -> None:
182
+ """Reject all pending requests with ``reason`` and stop accepting
183
+ replies."""
184
+ self._close(reason)
185
+
186
+
187
+ def create_link_driver(
188
+ io: LinkDriverIo,
189
+ *,
190
+ framer: NdjsonFramer | None = None,
191
+ timings: Mapping[str, int] | None = None,
192
+ on_signal: Callable[[Signal], None] | None = None,
193
+ on_ask: Callable[[Ask], Any] | None = None,
194
+ ) -> LinkDriverHandle:
195
+ """Build a :class:`LinkDriverHandle` over an injected transport.
196
+
197
+ The returned ``client`` generates a thunk per attribute access: calling
198
+ ``client.m(params)`` mints an id (prefixed with
199
+ :data:`REQUEST_ID_PREFIX`), writes the framed request, and resolves when
200
+ the matching reply arrives (or raises on an error reply or timeout). The
201
+ reader loop classifies inbound frames into replies, signals, and asks and
202
+ routes each. Must be called with a running event loop (the pump task
203
+ starts immediately).
204
+
205
+ :param io: the injected request sink + reply source
206
+ :param framer: override the NDJSON framer (defaults to the shared one)
207
+ :param timings: override any subset of the channel timings
208
+ :param on_signal: invoked for every uncorrelated signal the server streams
209
+ :param on_ask: invoked when the server asks a question; returns the answer
210
+ value (sync or awaitable). When omitted the driver dismisses every ask
211
+ (posts ``None``), so a non-interactive driver never wedges the server.
212
+ """
213
+ the_framer = framer if framer is not None else ndjson_framer
214
+ the_timings: ChannelTimings = resolve_timings(timings)
215
+
216
+ pending: dict[RequestId, _PendingRequest] = {}
217
+ state = {"seq": 0, "closed": False}
218
+
219
+ def next_id() -> str:
220
+ state["seq"] += 1
221
+ return mint_wire_id(REQUEST_ID_PREFIX, int(time.time() * 1000), state["seq"])
222
+
223
+ async def request(method: str, params: Any = None) -> Any:
224
+ if state["closed"]:
225
+ raise RuntimeError("link driver is closed")
226
+ request_id = next_id()
227
+ frame: dict[str, Any] = {"jsonrpc": PROTOCOL_VERSION, "id": request_id, "method": method}
228
+ if params is not None: # JSON.stringify dropped the undefined params
229
+ frame["params"] = params
230
+
231
+ loop = asyncio.get_running_loop()
232
+ future: asyncio.Future[Any] = loop.create_future()
233
+
234
+ def on_timeout() -> None:
235
+ pending.pop(request_id, None)
236
+ if not future.done():
237
+ future.set_exception(TimeoutError(f"link request timed out: {method}"))
238
+
239
+ timer = loop.call_later(the_timings.requestMs / 1000, on_timeout)
240
+ pending[request_id] = _PendingRequest(future=future, timer=timer)
241
+ io.out.write(the_framer.encode_line(frame))
242
+ return await future
243
+
244
+ def settle_reply(reply: Mapping[str, Any]) -> None:
245
+ entry = pending.pop(reply["id"], None)
246
+ if entry is None:
247
+ return
248
+ entry.timer.cancel()
249
+ if entry.future.done():
250
+ return
251
+ if "result" in reply:
252
+ entry.future.set_result(reply["result"])
253
+ else:
254
+ error = reply.get("error")
255
+ wire = error if isinstance(error, Mapping) else {}
256
+ entry.future.set_exception(
257
+ LinkRequestError(
258
+ OpError(
259
+ code=int(wire.get("code", 0)),
260
+ message=str(wire.get("message", "")),
261
+ data=wire.get("data"),
262
+ )
263
+ )
264
+ )
265
+
266
+ async def answer_ask(frame: Mapping[str, Any]) -> None:
267
+ ask = Ask(id=frame["id"], kind=str(frame.get("kind", "")), payload=frame.get("payload"))
268
+ value: Any = None
269
+ if on_ask is not None:
270
+ value = on_ask(ask)
271
+ if inspect.isawaitable(value):
272
+ value = await value
273
+ io.out.write(the_framer.encode_line({"type": "answer", "id": ask.id, "value": value}))
274
+
275
+ def reject_all(reason: str) -> None:
276
+ for entry in pending.values():
277
+ entry.timer.cancel()
278
+ if not entry.future.done():
279
+ entry.future.set_exception(RuntimeError(reason))
280
+ pending.clear()
281
+
282
+ async def pump() -> None:
283
+ try:
284
+ async for frame in the_framer.decode_lines(io.in_):
285
+ if _is_reply_frame(frame):
286
+ settle_reply(frame)
287
+ continue
288
+ if _is_signal_frame(frame):
289
+ if on_signal is not None:
290
+ on_signal(Signal(name=str(frame.get("name", "")), body=frame.get("body")))
291
+ continue
292
+ if _is_ask_frame(frame):
293
+ await answer_ask(frame)
294
+ continue
295
+ # Unknown frame shape: forward-compatible no-op.
296
+ finally:
297
+ state["closed"] = True
298
+ reject_all("link closed before reply")
299
+
300
+ def close(reason: str) -> None:
301
+ state["closed"] = True
302
+ reject_all(reason)
303
+
304
+ return LinkDriverHandle(
305
+ client=LinkClient(request),
306
+ done=asyncio.create_task(pump()),
307
+ close=close,
308
+ )
@@ -0,0 +1,217 @@
1
+ """Link server — the data-driven JSON-RPC 2.0 dispatch loop
2
+ (port of TS ``src/channels/link/server.ts``).
3
+
4
+ The server owns the single reader over the inbound stream. For each framed
5
+ line it decides what the frame is and routes it: a request frame is
6
+ dispatched through the :data:`~induscode.channels.contract.OpRegistry` (a map
7
+ lookup, not a command switch) and its outcome is framed back as a reply; an
8
+ inbound answer frame is routed to the dialog bridge to settle a suspended
9
+ ``ask``. One round of the protocol is ``{id, method, params} -> {id,
10
+ result | error}``.
11
+
12
+ Everything that touches the outside world is injected: the request source,
13
+ the reply sink, the framer, and the conductor all arrive through the context
14
+ and options, so a test serves the link over an in-memory pipe pair with no
15
+ stdio. The dispatch itself is delegated to
16
+ :func:`~induscode.channels.ops.dispatch` from the ops module, so the server
17
+ holds no per-method knowledge.
18
+
19
+ Concurrency pin (plan rule 8): a request handler may itself suspend on a
20
+ dialog ``ask``, whose answer arrives as a later inbound frame **on this very
21
+ stream**. So requests are dispatched as concurrent ``asyncio`` tasks (tracked
22
+ in a set, gathered at stream end) rather than awaited in line — otherwise the
23
+ reader would block on a handler that needs the reader to make progress,
24
+ deadlocking the round trip. Answer frames are always delivered immediately so
25
+ a suspended ``ask`` can settle.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ from collections.abc import Mapping
32
+ from dataclasses import dataclass
33
+ from typing import Any
34
+
35
+ from induscode.channels.contract import (
36
+ OP_ERROR,
37
+ PROTOCOL_VERSION,
38
+ AskAnswer,
39
+ ChannelContext,
40
+ NdjsonFramer,
41
+ OpError,
42
+ OpRegistry,
43
+ ReadableChunks,
44
+ RequestId,
45
+ SessionConductor,
46
+ WritableLine,
47
+ resolve_timings,
48
+ )
49
+ from induscode.channels.framer import ndjson_framer
50
+ from induscode.channels.link.dialog import (
51
+ DialogBridgeDeps,
52
+ LinkedDialogBridge,
53
+ create_dialog_bridge,
54
+ )
55
+ from induscode.channels.ops import UnknownOpError, dispatch
56
+
57
+ __all__ = [
58
+ "LinkServer",
59
+ "LinkServerIo",
60
+ "create_link_server",
61
+ ]
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Transport seams
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class LinkServerIo:
71
+ """The injectable transport pair the server reads from and writes to.
72
+
73
+ Bundles the inbound request source and the outbound reply/signal sink so
74
+ tests pass in-memory pipes and production passes stdin / stdout adapters.
75
+ (TS named the inbound field ``in``; that is a Python keyword, hence
76
+ ``in_``.)
77
+ """
78
+
79
+ #: Framed inbound request / answer lines (stdin in production).
80
+ in_: ReadableChunks
81
+ #: Framed outbound reply / signal / dialog sink (stdout in production).
82
+ out: WritableLine
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class LinkServer:
87
+ """A running server: a task that settles when the inbound stream ends."""
88
+
89
+ #: Resolves when the request stream is exhausted and the loop has drained.
90
+ done: asyncio.Task[None]
91
+ #: The dialog bridge the running server feeds inbound answers to.
92
+ dialog: LinkedDialogBridge
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Frame classification
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ def _is_request_frame(frame: Any) -> bool:
101
+ """Whether a decoded frame looks like a JSON-RPC request (``method``
102
+ present as a string)."""
103
+ return isinstance(frame, Mapping) and isinstance(frame.get("method"), str)
104
+
105
+
106
+ def _is_answer_frame(frame: Any) -> bool:
107
+ """Whether a decoded frame is an inbound answer to a suspended ``ask``."""
108
+ return (
109
+ isinstance(frame, Mapping)
110
+ and frame.get("type") == "answer"
111
+ and isinstance(frame.get("id"), str)
112
+ )
113
+
114
+
115
+ def _to_op_error(error: BaseException) -> OpError:
116
+ """Map a raised exception to a framed :class:`OpError`, preserving a
117
+ known code."""
118
+ if isinstance(error, UnknownOpError):
119
+ return OpError(code=error.code, message=str(error), data={"method": error.method})
120
+ return OpError(code=OP_ERROR["handlerFailed"], message=str(error))
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Server
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ def create_link_server(
129
+ registry: OpRegistry,
130
+ conductor: SessionConductor,
131
+ io: LinkServerIo,
132
+ *,
133
+ framer: NdjsonFramer | None = None,
134
+ timings: Mapping[str, int] | None = None,
135
+ ) -> LinkServer:
136
+ """Start a link server over an op registry, a conductor, and an injected
137
+ transport.
138
+
139
+ Builds the shared :class:`ChannelContext` (conductor + framer + the
140
+ freshly wired dialog bridge), then consumes the inbound stream with a
141
+ single ``async for`` loop. Each frame is classified:
142
+
143
+ - an answer frame is delivered to the dialog bridge;
144
+ - a request frame is dispatched through ``registry`` as a concurrent
145
+ task; a request carrying an ``id`` gets exactly one correlated reply
146
+ (result or error); a notification (no ``id``) is dispatched for its
147
+ effect and never replied to;
148
+ - any other frame is ignored (a forward-compatible no-op).
149
+
150
+ Must be called with a running event loop (the pump task starts
151
+ immediately — the analogue of the TS promise returned by ``pump()``).
152
+
153
+ :param registry: the frozen :data:`OpRegistry` that drives dispatch
154
+ :param conductor: the session every op delegates to
155
+ :param io: the injected request source + reply sink
156
+ :param framer: override the NDJSON framer (defaults to the shared one)
157
+ :param timings: override any subset of the channel timings
158
+ :returns: a handle whose ``done`` resolves when the inbound stream ends
159
+ """
160
+ the_framer = framer if framer is not None else ndjson_framer
161
+ the_timings = resolve_timings(timings)
162
+
163
+ dialog = create_dialog_bridge(
164
+ DialogBridgeDeps(out=io.out, framer=the_framer, timings=the_timings)
165
+ )
166
+
167
+ ctx = ChannelContext(conductor=conductor, out=io.out, framer=the_framer, dialog=dialog)
168
+
169
+ def write_reply(reply: Mapping[str, Any]) -> None:
170
+ io.out.write(the_framer.encode_line(reply))
171
+
172
+ def reply_ok(request_id: RequestId, result: Any) -> None:
173
+ write_reply({"jsonrpc": PROTOCOL_VERSION, "id": request_id, "result": result})
174
+
175
+ def reply_err(request_id: RequestId, error: OpError) -> None:
176
+ wire_error: dict[str, Any] = {"code": error.code, "message": error.message}
177
+ if error.data is not None:
178
+ wire_error["data"] = error.data
179
+ write_reply({"jsonrpc": PROTOCOL_VERSION, "id": request_id, "error": wire_error})
180
+
181
+ async def handle_request(frame: Mapping[str, Any]) -> None:
182
+ wants_reply = "id" in frame
183
+ try:
184
+ result = await dispatch(registry, frame["method"], frame.get("params"), ctx)
185
+ if wants_reply:
186
+ reply_ok(frame["id"], result)
187
+ except Exception as error: # noqa: BLE001 — every handler fault frames as a reply
188
+ if wants_reply:
189
+ reply_err(frame["id"], _to_op_error(error))
190
+
191
+ async def pump() -> None:
192
+ # A request handler may itself suspend on a dialog `ask`, whose answer
193
+ # arrives as a later inbound frame on this very stream. So requests
194
+ # are dispatched concurrently (their tasks tracked) rather than
195
+ # awaited in line — otherwise the reader would block on a handler
196
+ # that needs the reader to make progress, deadlocking the round trip.
197
+ # Answer frames are always delivered immediately so a suspended `ask`
198
+ # can settle.
199
+ inflight: set[asyncio.Task[None]] = set()
200
+ try:
201
+ async for frame in the_framer.decode_lines(io.in_):
202
+ if _is_answer_frame(frame):
203
+ dialog.deliver(AskAnswer(id=frame["id"], value=frame.get("value")))
204
+ continue
205
+ if _is_request_frame(frame):
206
+ task = asyncio.create_task(handle_request(frame))
207
+ inflight.add(task)
208
+ task.add_done_callback(inflight.discard)
209
+ continue
210
+ # Unknown frame shape: forward-compatible no-op.
211
+ # Let any still-running handlers settle before the loop reports done.
212
+ if inflight:
213
+ await asyncio.gather(*inflight)
214
+ finally:
215
+ dialog.drain()
216
+
217
+ return LinkServer(done=asyncio.create_task(pump()), dialog=dialog)
@@ -0,0 +1,178 @@
1
+ """Oneshot channel — a single non-interactive run to settlement
2
+ (port of TS ``src/channels/oneshot.ts``).
3
+
4
+ Replaces the print mode. Given a
5
+ :class:`~induscode.channels.contract.ChannelContext` and a
6
+ :class:`~induscode.channels.contract.OneshotRequest`, it submits every prompt
7
+ to the conductor in order, streams the answer to the channel sink, and
8
+ resolves a process exit code.
9
+
10
+ The shape (clean final text vs a streamed NDJSON event log) is chosen by
11
+ selecting an :class:`~induscode.channels.contract.OneshotStrategy` object
12
+ rather than branching on a flag through the runner body: each strategy
13
+ supplies an optional ``on_start``, an optional ``on_signal``, and a mandatory
14
+ ``finish`` that turns the settled state into the exit code. The runner itself
15
+ is shape-agnostic — it subscribes the strategy to the conductor signal
16
+ stream, runs the prompts, and hands the final state to ``finish``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import inspect
22
+ from typing import Any, Final, Mapping
23
+
24
+ from induscode.channels.contract import (
25
+ ChannelContext,
26
+ ConductorState,
27
+ DialogBridge,
28
+ OneshotRequest,
29
+ OneshotShape,
30
+ OneshotStrategy,
31
+ signal_to_wire,
32
+ )
33
+ from induscode.conductor.contract import SessionSignal
34
+ from induscode.conductor.serialize import message_to_dict
35
+
36
+ __all__ = [
37
+ "inert_dialog",
38
+ "run_oneshot",
39
+ ]
40
+
41
+
42
+ class _InertDialog:
43
+ """A dialog bridge for a non-interactive oneshot run: no driver is
44
+ attached to answer a question, so every ``ask`` resolves immediately with
45
+ its caller-supplied fallback and every ``tell`` is dropped. Keeps the
46
+ agent from wedging on a prompt during a headless single run while never
47
+ writing dialog frames to the sink."""
48
+
49
+ async def ask(self, kind: str, payload: Any, fallback: Any) -> Any:
50
+ return fallback
51
+
52
+ def tell(self, kind: str, payload: Any = None) -> None:
53
+ # Fire-and-forget with no listener: intentionally discarded.
54
+ return None
55
+
56
+
57
+ #: The shared inert bridge instance (TS frozen ``inertDialog``).
58
+ inert_dialog: Final[DialogBridge] = _InertDialog()
59
+
60
+
61
+ #: Exit code for a clean settlement.
62
+ _EXIT_OK: Final[int] = 0
63
+
64
+ #: Exit code for a faulted settlement.
65
+ _EXIT_FAULT: Final[int] = 1
66
+
67
+
68
+ def _exit_code_for(state: ConductorState) -> int:
69
+ """Pick the exit code from a settled :class:`ConductorState`: a faulted
70
+ phase is a failure, anything else is success. Shared by both strategies so
71
+ the success rule lives in one place."""
72
+ return _EXIT_FAULT if state.phase == "faulted" else _EXIT_OK
73
+
74
+
75
+ def _text_strategy() -> OneshotStrategy:
76
+ """The text strategy: accumulate streamed answer text and emit it once,
77
+ clean.
78
+
79
+ ``on_signal`` collects every ``text`` delta into a buffer; ``finish``
80
+ writes the accumulated answer as a single trailing line (so a caller
81
+ capturing stdout gets the final answer, not a token-by-token dribble) and
82
+ returns the exit code. Thinking deltas, tool frames, and bookkeeping
83
+ signals are ignored.
84
+ """
85
+ parts: list[str] = []
86
+
87
+ def on_signal(signal: SessionSignal, ctx: ChannelContext) -> None:
88
+ if signal.kind == "text":
89
+ parts.append(signal.delta) # type: ignore[union-attr]
90
+
91
+ def finish(state: ConductorState, ctx: ChannelContext) -> int:
92
+ answer = "".join(parts)
93
+ ctx.out.write(answer if answer.endswith("\n") else answer + "\n")
94
+ return _exit_code_for(state)
95
+
96
+ return OneshotStrategy(finish=finish, on_signal=on_signal)
97
+
98
+
99
+ def _ndjson_strategy() -> OneshotStrategy:
100
+ """The NDJSON strategy: stream every conductor signal as one framed line.
101
+
102
+ ``on_start`` emits an opening frame announcing the run; ``on_signal``
103
+ frames each :data:`SessionSignal` (projected through ``signal_to_wire``)
104
+ via the shared framer; ``finish`` emits a closing frame carrying the
105
+ settled phase and usage, then returns the exit code. Every line is
106
+ separator-safe by construction (the framer escapes the two Unicode line
107
+ separators).
108
+ """
109
+
110
+ def on_start(ctx: ChannelContext) -> None:
111
+ ctx.out.write(ctx.framer.encode_line({"type": "signal", "name": "start", "body": {}}))
112
+
113
+ def on_signal(signal: SessionSignal, ctx: ChannelContext) -> None:
114
+ ctx.out.write(
115
+ ctx.framer.encode_line(
116
+ {"type": "signal", "name": signal.kind, "body": signal_to_wire(signal)}
117
+ )
118
+ )
119
+
120
+ def finish(state: ConductorState, ctx: ChannelContext) -> int:
121
+ body: dict[str, Any] = {
122
+ "phase": state.phase,
123
+ "usage": message_to_dict(state.usage),
124
+ }
125
+ # TS framed `fault: undefined` away via JSON.stringify; omit None here.
126
+ if state.fault is not None:
127
+ body["fault"] = message_to_dict(state.fault)
128
+ ctx.out.write(ctx.framer.encode_line({"type": "signal", "name": "end", "body": body}))
129
+ return _exit_code_for(state)
130
+
131
+ return OneshotStrategy(finish=finish, on_start=on_start, on_signal=on_signal)
132
+
133
+
134
+ #: The strategy table: one entry per :data:`OneshotShape`. Selecting a shape
135
+ #: is looking up its row, not threading an ``if`` through the runner.
136
+ _STRATEGIES: Final[Mapping[OneshotShape, Any]] = {
137
+ "text": _text_strategy,
138
+ "ndjson": _ndjson_strategy,
139
+ }
140
+
141
+
142
+ async def run_oneshot(ctx: ChannelContext, opts: OneshotRequest) -> int:
143
+ """Run one non-interactive request to settlement.
144
+
145
+ Selects the :class:`OneshotStrategy` for ``opts.shape``, subscribes it to
146
+ the conductor signal stream, runs ``on_start``, then submits each prompt
147
+ in order and awaits its settlement. The state from the final prompt is
148
+ handed to the strategy's ``finish``, whose return value is the resolved
149
+ exit code. The subscription is always torn down before returning.
150
+
151
+ :param ctx: the channel context (conductor, sink, framer, dialog) — fully
152
+ injected
153
+ :param opts: the request: output shape, the prompts to run, and any images
154
+ :returns: the process exit code (0 on a clean settlement, 1 on a fault)
155
+ """
156
+ strategy: OneshotStrategy = _STRATEGIES[opts.shape]()
157
+
158
+ def relay(signal: SessionSignal) -> None:
159
+ if strategy.on_signal is not None:
160
+ strategy.on_signal(signal, ctx)
161
+
162
+ unsubscribe = ctx.conductor.subscribe(relay)
163
+ try:
164
+ if strategy.on_start is not None:
165
+ started = strategy.on_start(ctx)
166
+ if inspect.isawaitable(started):
167
+ await started
168
+
169
+ state: ConductorState = ctx.conductor.snapshot()
170
+ for prompt in opts.prompts:
171
+ state = await ctx.conductor.submit(prompt)
172
+
173
+ finished = strategy.finish(state, ctx)
174
+ if inspect.isawaitable(finished):
175
+ finished = await finished
176
+ return int(finished)
177
+ finally:
178
+ unsubscribe()