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,198 @@
1
+ """``claude-cli`` bridge — drives an Anthropic-flavoured CLI that emits
2
+ line-delimited stream-json content blocks (port of TS
3
+ ``src/runtime-bridge/bridges/claude-cli.ts``).
4
+
5
+ Wire dialect (each inbound ``ChildMessage.payload`` is one parsed JSON line
6
+ of the CLI's NDJSON stdout):
7
+
8
+ - ``{"type": "system", "subtype": "init", "session_id": …}`` — the CLI
9
+ announces the session id it allocated; surfaced as a ``resume`` event so
10
+ the broker can persist it and reattach the same CLI session later.
11
+ - ``{"type": "content_block_start", "content_block": {"type": …}}`` — a
12
+ block opens; a ``tool_use`` block carries ``{id, name, input}`` which
13
+ becomes a fully formed tool call immediately (the CLI batches the input).
14
+ - ``{"type": "content_block_delta", "delta": {"type", "text" | "thinking"}}``
15
+ — a ``text_delta`` / ``thinking_delta`` chunk; mapped to a ``text`` /
16
+ ``thinking`` normalized event. ``input_json_delta`` chunks (streamed tool
17
+ arguments) are ignored here because the bridge emits the tool call from
18
+ the terminal ``content_block_stop`` snapshot.
19
+ - ``{"type": "message_delta", "delta": {"stop_reason": …}}`` — carries the
20
+ final stop reason, recorded for the eventual ``finish``.
21
+ - ``{"type": "message_stop"}`` / ``{"type": "result"}`` — the turn is over;
22
+ finish with the recorded reason.
23
+ - ``{"type": "error", "error": {"message": …}}`` — a runtime fault.
24
+
25
+ The opening request is the user turn forwarded as the CLI's prompt body; the
26
+ spec's binary/args/env are the broker's concern when it builds the
27
+ transport, so this bridge only formats the protocol body.
28
+
29
+ The child process is reached exclusively through the injected
30
+ ``ChildTransport``; no ``claude`` binary is spawned here.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from collections.abc import Mapping
36
+ from typing import Any, Final
37
+
38
+ from induscode.runtime_bridge.contract import (
39
+ AssistantMessageEventStream,
40
+ BridgeEventSink,
41
+ BridgeFailure,
42
+ ChildMessage,
43
+ ChildRequest,
44
+ ChildTransport,
45
+ Context,
46
+ ExchangeOptions,
47
+ ExternalRuntimeSpec,
48
+ FinishReason,
49
+ Model,
50
+ ResumeEvent,
51
+ RuntimeAdapterId,
52
+ RuntimeBridge,
53
+ ToolCall,
54
+ )
55
+
56
+ from ._drive import CONTINUE, DONE, ChildParser, ParseStep, as_record, drive_exchange, str_field
57
+
58
+ __all__ = [
59
+ "claude_cli_bridge",
60
+ ]
61
+
62
+ #: The adapter id this bridge answers to.
63
+ _ADAPTER: Final = "claude-cli"
64
+
65
+
66
+ def _map_stop_reason(raw: str | None) -> FinishReason:
67
+ """Map an Anthropic ``stop_reason`` string onto a normalized
68
+ :data:`~induscode.runtime_bridge.contract.FinishReason`."""
69
+ if raw == "max_tokens":
70
+ return "length"
71
+ if raw == "tool_use":
72
+ return "toolUse"
73
+ # "end_turn" / "stop_sequence" / anything else settles as a plain stop.
74
+ return "stop"
75
+
76
+
77
+ def _tool_call_from_block(block: Mapping[str, Any]) -> ToolCall | None:
78
+ """Extract a fully-formed ``ToolCall`` from a ``tool_use`` content block."""
79
+ id_ = str_field(block, "id")
80
+ name = str_field(block, "name")
81
+ if id_ is None or name is None:
82
+ return None
83
+ input_ = as_record(block.get("input")) or {}
84
+ return ToolCall(id=id_, name=name, arguments=input_)
85
+
86
+
87
+ def _make_parser() -> ChildParser:
88
+ """Parse one stream-json line into sink emissions. Records the last-seen
89
+ stop reason on a small per-exchange closure so ``message_stop`` can
90
+ finish with it."""
91
+ stop_reason: FinishReason = "stop"
92
+
93
+ def parse(message: ChildMessage, sink: BridgeEventSink) -> ParseStep:
94
+ nonlocal stop_reason
95
+ payload = as_record(message.payload)
96
+ if payload is None:
97
+ return CONTINUE
98
+ type_ = str_field(payload, "type")
99
+
100
+ match type_:
101
+ case "system":
102
+ # Session init: surface the allocated session id as a resume token.
103
+ if str_field(payload, "subtype") == "init":
104
+ session_id = str_field(payload, "session_id")
105
+ if session_id is not None:
106
+ sink.emit(ResumeEvent(resumeToken=session_id))
107
+ return CONTINUE
108
+
109
+ case "content_block_start":
110
+ block = as_record(payload.get("content_block"))
111
+ if block is not None and str_field(block, "type") == "tool_use":
112
+ call = _tool_call_from_block(block)
113
+ if call is not None:
114
+ sink.tool_call(call)
115
+ return CONTINUE
116
+
117
+ case "content_block_delta":
118
+ delta = as_record(payload.get("delta"))
119
+ if delta is None:
120
+ return CONTINUE
121
+ delta_type = str_field(delta, "type")
122
+ if delta_type == "text_delta":
123
+ text = str_field(delta, "text")
124
+ if text is not None:
125
+ sink.text(text)
126
+ elif delta_type == "thinking_delta":
127
+ thinking = str_field(delta, "thinking")
128
+ if thinking is not None:
129
+ sink.thinking(thinking)
130
+ # input_json_delta is intentionally dropped — see module header.
131
+ return CONTINUE
132
+
133
+ case "message_delta":
134
+ delta = as_record(payload.get("delta"))
135
+ stop_reason = _map_stop_reason(
136
+ str_field(delta, "stop_reason") if delta is not None else None
137
+ )
138
+ return CONTINUE
139
+
140
+ case "error":
141
+ error = as_record(payload.get("error"))
142
+ detail = (
143
+ str_field(error, "message") if error is not None else None
144
+ ) or "claude-cli error"
145
+ sink.finish_error(BridgeFailure(message=detail, cause=payload.get("error")))
146
+ return DONE
147
+
148
+ case "message_stop" | "result":
149
+ sink.finish_success(stop_reason)
150
+ return DONE
151
+
152
+ case _:
153
+ return CONTINUE
154
+
155
+ return parse
156
+
157
+
158
+ def _opening_request(context: Context, opts: ExchangeOptions) -> ChildRequest:
159
+ """Build the opening request: forward the conversation context as the CLI
160
+ prompt body, threading the resume token / cwd through so the transport
161
+ layer can attach the right CLI session."""
162
+ return ChildRequest(
163
+ body={
164
+ "type": "user",
165
+ "context": context,
166
+ "resume": opts.resume,
167
+ "cwd": opts.cwd,
168
+ "sessionId": opts.sessionId,
169
+ }
170
+ )
171
+
172
+
173
+ class _ClaudeCliBridge:
174
+ """The ``claude-cli`` :class:`RuntimeBridge`. Stateless on the contract
175
+ surface: it parses the injected transport's stream-json into the sink and
176
+ returns the framework push stream."""
177
+
178
+ adapter: RuntimeAdapterId = _ADAPTER
179
+
180
+ def run_exchange(
181
+ self,
182
+ model: Model,
183
+ context: Context,
184
+ opts: ExchangeOptions,
185
+ transport: ChildTransport,
186
+ ) -> AssistantMessageEventStream:
187
+ return drive_exchange(
188
+ model, opts, transport, _opening_request(context, opts), _make_parser()
189
+ )
190
+
191
+ def requires_credential(self, spec: ExternalRuntimeSpec) -> bool:
192
+ # An external CLI that owns its own login needs no key on disk; an
193
+ # api-key spec still resolves a credential from the vault.
194
+ return spec.authMode == "api-key"
195
+
196
+
197
+ #: The shipped ``claude-cli`` bridge singleton.
198
+ claude_cli_bridge: RuntimeBridge = _ClaudeCliBridge()
@@ -0,0 +1,203 @@
1
+ """``codex-cli`` bridge — drives an OpenAI-flavoured CLI emitting ``--json``
2
+ turn/item events (port of TS ``src/runtime-bridge/bridges/codex-cli.ts``).
3
+
4
+ Wire dialect (each inbound ``ChildMessage.payload`` is one parsed ``--json``
5
+ event object):
6
+
7
+ - ``{"type": "thread.started", "thread_id": …}`` — the CLI opened a thread;
8
+ surfaced as a ``resume`` event so the broker can persist the thread id and
9
+ continue it on a later exchange.
10
+ - ``{"type": "response.output_text.delta", "delta": …}`` — a chunk of answer
11
+ text.
12
+ - ``{"type": "response.reasoning_text.delta" | "response.reasoning.delta",
13
+ "delta": …}`` — a chunk of reasoning text.
14
+ - ``{"type": "response.output_item.done", "item": …}`` /
15
+ ``{"type": "item.completed", "item": …}`` — a completed item; a
16
+ ``function_call`` / ``tool_call`` item becomes a fully-formed tool call
17
+ (its ``arguments`` string is parsed into an object).
18
+ - ``{"type": "turn.completed" | "response.completed"}`` — the turn settled;
19
+ finish (tool-bearing turns settle as ``toolUse``, else ``stop``).
20
+ - ``{"type": "error" | "turn.failed", "error" | "message": …}`` — a runtime
21
+ fault.
22
+
23
+ Item-level ``*.delta`` text is preferred; if a CLI variant only emits whole
24
+ items, the completed-item handler still surfaces the text. The injected
25
+ ``ChildTransport`` carries the events — no ``codex`` binary is spawned here.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ from collections.abc import Mapping
32
+ from typing import Any, Final
33
+
34
+ from induscode.runtime_bridge.contract import (
35
+ AssistantMessageEventStream,
36
+ BridgeEventSink,
37
+ BridgeFailure,
38
+ ChildMessage,
39
+ ChildRequest,
40
+ ChildTransport,
41
+ Context,
42
+ ExchangeOptions,
43
+ ExternalRuntimeSpec,
44
+ FinishReason,
45
+ Model,
46
+ ResumeEvent,
47
+ RuntimeAdapterId,
48
+ RuntimeBridge,
49
+ ToolCall,
50
+ )
51
+
52
+ from ._drive import CONTINUE, DONE, ChildParser, ParseStep, as_record, drive_exchange, str_field
53
+
54
+ __all__ = [
55
+ "codex_cli_bridge",
56
+ ]
57
+
58
+ #: The adapter id this bridge answers to.
59
+ _ADAPTER: Final = "codex-cli"
60
+
61
+
62
+ def _parse_arguments(value: Any) -> Mapping[str, Any]:
63
+ """Parse a ``--json`` item's ``arguments`` (a JSON string or an object)
64
+ into a record."""
65
+ if isinstance(value, str):
66
+ if len(value) == 0:
67
+ return {}
68
+ try:
69
+ parsed: Any = json.loads(value)
70
+ except (ValueError, TypeError):
71
+ return {}
72
+ return as_record(parsed) or {}
73
+ return as_record(value) or {}
74
+
75
+
76
+ def _tool_call_from_item(item: Mapping[str, Any]) -> ToolCall | None:
77
+ """Build a ``ToolCall`` from a completed ``function_call`` /
78
+ ``tool_call`` item."""
79
+ name = str_field(item, "name")
80
+ if name is None:
81
+ return None
82
+ id_ = str_field(item, "call_id") or str_field(item, "id") or name
83
+ return ToolCall(id=id_, name=name, arguments=_parse_arguments(item.get("arguments")))
84
+
85
+
86
+ def _is_tool_item(item: Mapping[str, Any]) -> bool:
87
+ """True when a completed item is a tool invocation."""
88
+ item_type = str_field(item, "type")
89
+ return item_type == "function_call" or item_type == "tool_call"
90
+
91
+
92
+ def _make_parser() -> ChildParser:
93
+ """Parse one ``--json`` event into sink emissions. Tracks whether any
94
+ tool call was surfaced so the terminal ``turn.completed`` can settle as
95
+ ``toolUse``."""
96
+ saw_tool_call = False
97
+
98
+ def handle_completed_item(item: Mapping[str, Any], sink: BridgeEventSink) -> None:
99
+ nonlocal saw_tool_call
100
+ if _is_tool_item(item):
101
+ call = _tool_call_from_item(item)
102
+ if call is not None:
103
+ sink.tool_call(call)
104
+ saw_tool_call = True
105
+ return
106
+ # A non-tool completed item may carry the full text for CLI variants
107
+ # that do not stream `output_text.delta`; surface it if present and
108
+ # non-empty.
109
+ text = str_field(item, "text")
110
+ if text is not None and len(text) > 0:
111
+ sink.text(text)
112
+
113
+ def parse(message: ChildMessage, sink: BridgeEventSink) -> ParseStep:
114
+ payload = as_record(message.payload)
115
+ if payload is None:
116
+ return CONTINUE
117
+ type_ = str_field(payload, "type")
118
+
119
+ match type_:
120
+ case "thread.started":
121
+ thread_id = str_field(payload, "thread_id")
122
+ if thread_id is not None:
123
+ sink.emit(ResumeEvent(resumeToken=thread_id))
124
+ return CONTINUE
125
+
126
+ case "response.output_text.delta":
127
+ delta = str_field(payload, "delta")
128
+ if delta is not None:
129
+ sink.text(delta)
130
+ return CONTINUE
131
+
132
+ case "response.reasoning_text.delta" | "response.reasoning.delta":
133
+ delta = str_field(payload, "delta")
134
+ if delta is not None:
135
+ sink.thinking(delta)
136
+ return CONTINUE
137
+
138
+ case "response.output_item.done" | "item.completed":
139
+ item = as_record(payload.get("item"))
140
+ if item is not None:
141
+ handle_completed_item(item, sink)
142
+ return CONTINUE
143
+
144
+ case "error" | "turn.failed":
145
+ error = as_record(payload.get("error"))
146
+ detail = (
147
+ (str_field(error, "message") if error is not None else None)
148
+ or str_field(payload, "message")
149
+ or "codex-cli error"
150
+ )
151
+ cause = payload.get("error")
152
+ sink.finish_error(
153
+ BridgeFailure(message=detail, cause=cause if cause is not None else payload)
154
+ )
155
+ return DONE
156
+
157
+ case "turn.completed" | "response.completed":
158
+ reason: FinishReason = "toolUse" if saw_tool_call else "stop"
159
+ sink.finish_success(reason)
160
+ return DONE
161
+
162
+ case _:
163
+ return CONTINUE
164
+
165
+ return parse
166
+
167
+
168
+ def _opening_request(context: Context, opts: ExchangeOptions) -> ChildRequest:
169
+ """Build the opening request: the conversation context as the CLI turn
170
+ input."""
171
+ return ChildRequest(
172
+ body={
173
+ "type": "turn",
174
+ "context": context,
175
+ "thread": opts.resume,
176
+ "cwd": opts.cwd,
177
+ "sessionId": opts.sessionId,
178
+ }
179
+ )
180
+
181
+
182
+ class _CodexCliBridge:
183
+ """The ``codex-cli`` :class:`RuntimeBridge`."""
184
+
185
+ adapter: RuntimeAdapterId = _ADAPTER
186
+
187
+ def run_exchange(
188
+ self,
189
+ model: Model,
190
+ context: Context,
191
+ opts: ExchangeOptions,
192
+ transport: ChildTransport,
193
+ ) -> AssistantMessageEventStream:
194
+ return drive_exchange(
195
+ model, opts, transport, _opening_request(context, opts), _make_parser()
196
+ )
197
+
198
+ def requires_credential(self, spec: ExternalRuntimeSpec) -> bool:
199
+ return spec.authMode == "api-key"
200
+
201
+
202
+ #: The shipped ``codex-cli`` bridge singleton.
203
+ codex_cli_bridge: RuntimeBridge = _CodexCliBridge()
@@ -0,0 +1,217 @@
1
+ """``indusagi-cli`` bridge — drives a peer agent over JSON-RPC (port of TS
2
+ ``src/runtime-bridge/bridges/indusagi-cli.ts``).
3
+
4
+ Unlike the CLI bridges, the peer already speaks (a transport-framed form of)
5
+ the framework event vocabulary, so its messages map onto
6
+ :data:`~induscode.runtime_bridge.contract.NormalizedEvent` near-directly.
7
+ Each inbound ``ChildMessage.payload`` is a JSON-RPC frame:
8
+
9
+ - a **notification** ``{"method", "params"}`` streams one turn event:
10
+
11
+ - ``stream/text`` params ``{delta}`` → ``text``
12
+ - ``stream/thinking`` params ``{delta}`` → ``thinking``
13
+ - ``stream/toolCall`` params ``{id, name, arguments}`` → ``tool_call``
14
+ - ``session/resume`` params ``{resumeToken}`` → ``resume``
15
+ - ``stream/done`` params ``{reason}`` → ``finish``
16
+ - ``stream/error`` params ``{message, aborted}`` → ``failed``
17
+
18
+ - a **response** ``{"id", "result" | "error"}`` to the opening
19
+ ``runExchange`` request: an ``error`` member fails the exchange; a
20
+ ``result`` is treated as a terminal acknowledgement (``finish``) if the
21
+ stream has not already settled.
22
+
23
+ The opening request is a JSON-RPC ``runExchange`` call carrying the context,
24
+ delegate provider, and resume token. The peer is reached only through the
25
+ injected ``ChildTransport``; no peer process is spawned here.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from collections.abc import Mapping
31
+ from typing import Any, Final
32
+
33
+ from induscode.runtime_bridge.contract import (
34
+ AssistantMessageEventStream,
35
+ BridgeEventSink,
36
+ BridgeFailure,
37
+ ChildMessage,
38
+ ChildRequest,
39
+ ChildTransport,
40
+ Context,
41
+ ExchangeOptions,
42
+ ExternalRuntimeSpec,
43
+ FinishReason,
44
+ Model,
45
+ ResumeEvent,
46
+ RuntimeAdapterId,
47
+ RuntimeBridge,
48
+ ToolCall,
49
+ )
50
+
51
+ from ._drive import CONTINUE, DONE, ChildParser, ParseStep, as_record, drive_exchange, str_field
52
+
53
+ __all__ = [
54
+ "indusagi_cli_bridge",
55
+ "make_indusagi_cli_bridge",
56
+ ]
57
+
58
+ #: The adapter id this bridge answers to.
59
+ _ADAPTER: Final = "indusagi-cli"
60
+
61
+ #: The JSON-RPC method invoked to start a peer exchange.
62
+ _RPC_METHOD: Final = "runExchange"
63
+
64
+ #: The peer's finish reasons are the framework's own; narrow defensively.
65
+ _ALLOWED_REASONS: Final[tuple[FinishReason, ...]] = ("stop", "length", "toolUse")
66
+
67
+
68
+ def _as_finish_reason(raw: str | None) -> FinishReason:
69
+ return raw if raw in _ALLOWED_REASONS else "stop" # type: ignore[return-value]
70
+
71
+
72
+ def _tool_call_from_params(params: Mapping[str, Any]) -> ToolCall | None:
73
+ """Build a ``ToolCall`` from a ``stream/toolCall`` notification's params."""
74
+ id_ = str_field(params, "id")
75
+ name = str_field(params, "name")
76
+ if id_ is None or name is None:
77
+ return None
78
+ return ToolCall(id=id_, name=name, arguments=as_record(params.get("arguments")) or {})
79
+
80
+
81
+ def _handle_notification(
82
+ method: str,
83
+ params: Mapping[str, Any] | None,
84
+ sink: BridgeEventSink,
85
+ ) -> ParseStep:
86
+ """Dispatch one JSON-RPC notification by method."""
87
+ match method:
88
+ case "stream/text":
89
+ delta = str_field(params, "delta") if params is not None else None
90
+ if delta is not None:
91
+ sink.text(delta)
92
+ return CONTINUE
93
+ case "stream/thinking":
94
+ delta = str_field(params, "delta") if params is not None else None
95
+ if delta is not None:
96
+ sink.thinking(delta)
97
+ return CONTINUE
98
+ case "stream/toolCall":
99
+ call = _tool_call_from_params(params) if params is not None else None
100
+ if call is not None:
101
+ sink.tool_call(call)
102
+ return CONTINUE
103
+ case "session/resume":
104
+ token = str_field(params, "resumeToken") if params is not None else None
105
+ if token is not None:
106
+ sink.emit(ResumeEvent(resumeToken=token))
107
+ return CONTINUE
108
+ case "stream/done":
109
+ sink.finish_success(
110
+ _as_finish_reason(str_field(params, "reason") if params is not None else None)
111
+ )
112
+ return DONE
113
+ case "stream/error":
114
+ message = (
115
+ str_field(params, "message") if params is not None else None
116
+ ) or "indusagi-cli error"
117
+ aborted = params is not None and params.get("aborted") is True
118
+ sink.finish_error(BridgeFailure(message=message, aborted=aborted, cause=params))
119
+ return DONE
120
+ case _:
121
+ return CONTINUE
122
+
123
+
124
+ def _parse_frame(message: ChildMessage, sink: BridgeEventSink) -> ParseStep:
125
+ """Parse one JSON-RPC frame (notification or response) into sink
126
+ emissions."""
127
+ frame = as_record(message.payload)
128
+ if frame is None:
129
+ return CONTINUE
130
+
131
+ # A notification carries a `method`; a response carries `result` / `error`.
132
+ method = str_field(frame, "method")
133
+ if method is not None:
134
+ return _handle_notification(method, as_record(frame.get("params")), sink)
135
+
136
+ # Response to the opening call.
137
+ if "error" in frame:
138
+ error = as_record(frame.get("error"))
139
+ detail = (
140
+ str_field(error, "message") if error is not None else None
141
+ ) or "indusagi-cli rpc error"
142
+ sink.finish_error(BridgeFailure(message=detail, cause=frame.get("error")))
143
+ return DONE
144
+ if "result" in frame:
145
+ # A bare result acknowledgement settles the exchange if streaming
146
+ # notifications did not already emit a terminal `stream/done`.
147
+ return DONE
148
+ return CONTINUE
149
+
150
+
151
+ #: The reusable parser (no per-exchange state; the frame carries everything).
152
+ _parser: ChildParser = _parse_frame
153
+
154
+
155
+ def _opening_request(
156
+ context: Context, opts: ExchangeOptions, spec: ExternalRuntimeSpec
157
+ ) -> ChildRequest:
158
+ """Build the opening JSON-RPC ``runExchange`` request."""
159
+ return ChildRequest(
160
+ body={
161
+ "jsonrpc": "2.0",
162
+ "id": opts.sessionId if opts.sessionId is not None else _RPC_METHOD,
163
+ "method": _RPC_METHOD,
164
+ "params": {
165
+ "context": context,
166
+ "delegate": spec.delegate,
167
+ "resume": opts.resume,
168
+ "cwd": opts.cwd,
169
+ "sessionId": opts.sessionId,
170
+ },
171
+ }
172
+ )
173
+
174
+
175
+ class _IndusagiCliBridge:
176
+ """An ``indusagi-cli`` :class:`RuntimeBridge` bound to one spec (so the
177
+ opening request can forward its ``delegate`` to the peer)."""
178
+
179
+ adapter: RuntimeAdapterId = _ADAPTER
180
+
181
+ __slots__ = ("_spec",)
182
+
183
+ def __init__(self, spec: ExternalRuntimeSpec) -> None:
184
+ self._spec = spec
185
+
186
+ def run_exchange(
187
+ self,
188
+ model: Model,
189
+ context: Context,
190
+ opts: ExchangeOptions,
191
+ transport: ChildTransport,
192
+ ) -> AssistantMessageEventStream:
193
+ return drive_exchange(
194
+ model, opts, transport, _opening_request(context, opts, self._spec), _parser
195
+ )
196
+
197
+ def requires_credential(self, spec: ExternalRuntimeSpec) -> bool:
198
+ return spec.authMode == "api-key"
199
+
200
+
201
+ def make_indusagi_cli_bridge(spec: ExternalRuntimeSpec) -> RuntimeBridge:
202
+ """Build the ``indusagi-cli`` :class:`RuntimeBridge` (TS
203
+ ``makeIndusagiCliBridge``). The bound :class:`ExternalRuntimeSpec` is
204
+ captured so the opening request can forward its ``delegate`` to the peer;
205
+ the broker constructs one bridge per spec it routes to.
206
+
207
+ :param spec: the runtime annotation whose ``delegate`` the peer should target
208
+ """
209
+ return _IndusagiCliBridge(spec)
210
+
211
+
212
+ #: A default ``indusagi-cli`` bridge bound to a spec with no delegate. Use
213
+ #: :func:`make_indusagi_cli_bridge` when a delegate provider must be
214
+ #: forwarded.
215
+ indusagi_cli_bridge: RuntimeBridge = make_indusagi_cli_bridge(
216
+ ExternalRuntimeSpec(adapter=_ADAPTER, authMode="external-cli")
217
+ )