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
induscode/boot/boot.py ADDED
@@ -0,0 +1,210 @@
1
+ """Boot orchestrator — the single function the OS entry point calls.
2
+
3
+ Port of TS ``src/boot/boot.ts``. :func:`boot` owns the whole launch arc, in
4
+ VERBATIM TS routing order:
5
+
6
+ 1. **Credential command** (``signin`` / ``signout`` / ``login`` / ``logout``
7
+ as ``argv[0]``) → :func:`~induscode.launch.run_credential_command` over
8
+ the disk vault; exits.
9
+ 2. **Package command** (``install`` / ``remove`` / ``update`` / ``list`` /
10
+ ``config`` as ``argv[0]``) →
11
+ :func:`~induscode.launch.run_package_command` over the preference store;
12
+ exits.
13
+ 3. **Meta short-circuits**: ``--help`` (usage from the one declarative flag
14
+ table), ``--version``, ``--list-models [filter]`` — all before any stage
15
+ runs or any directory is touched.
16
+ 4. **Stage pipeline** :func:`~.stages.run_stages` (immutable context fold).
17
+ 5. **Runner dispatch** via :func:`~.runners.select_runner`; the ``finally``
18
+ drains :attr:`~.contract.BootContext.closables` latest-registered first,
19
+ swallowing teardown errors.
20
+
21
+ :func:`boot` itself never raises to its caller for an expected failure — it
22
+ maps problems to a non-zero exit code so the entry point can simply adopt it.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import inspect
28
+ import sys
29
+ from collections.abc import Sequence
30
+ from typing import Final
31
+
32
+ from induscode.launch import (
33
+ CatalogFilter,
34
+ format_credential_fault,
35
+ print_model_catalog,
36
+ register_built_in_oauth_providers,
37
+ render_usage,
38
+ run_credential_command,
39
+ run_package_command,
40
+ )
41
+ from induscode.settings import PreferenceStore
42
+ from induscode.workspace import BRAND, VERSION, create_workspace
43
+
44
+ from .auth_vault import create_auth_vault
45
+ from .contract import BootContext
46
+ from .invocation import tokenize_invocation, wants_help, wants_version
47
+ from .runners import select_runner
48
+ from .stages import run_stages
49
+
50
+ __all__ = ["boot"]
51
+
52
+ #: Exit code for a clean run.
53
+ _EXIT_OK: Final[int] = 0
54
+
55
+ #: Exit code for an unexpected failure during boot.
56
+ _EXIT_FAILURE: Final[int] = 1
57
+
58
+ #: Exit code returned when a handled credential command did not complete.
59
+ _EXIT_CREDENTIAL_FAULT: Final[int] = 1
60
+
61
+
62
+ def _seed_context(argv: Sequence[str]) -> BootContext:
63
+ """Build the seed :class:`~.contract.BootContext` from the sliced
64
+ ``argv``.
65
+
66
+ Resolves the workspace (pure path computation — directories are
67
+ materialised later by the pipeline) and the brand, parses the command
68
+ line once up front so the meta-request check can run before the pipeline,
69
+ and seeds an empty closables list. The pipeline re-parses the invocation
70
+ in its ``build-invocation`` stage; seeding it here keeps the context
71
+ fully-typed and lets ``--help`` / ``--version`` short-circuit without
72
+ running any stage.
73
+ """
74
+ return BootContext(
75
+ argv=tuple(argv),
76
+ workspace=create_workspace(),
77
+ brand=BRAND,
78
+ invocation=tokenize_invocation(argv),
79
+ closables=[],
80
+ )
81
+
82
+
83
+ def _render_help() -> None:
84
+ """Render the help banner to stdout. Delegates to the launch
85
+ :func:`~induscode.launch.render_usage`, which generates the full option
86
+ reference from the single declarative flag table — so the help text can
87
+ never drift from what the parser accepts."""
88
+ sys.stdout.write(f"{render_usage()}\n")
89
+
90
+
91
+ def _render_version() -> None:
92
+ """Render the version string to stdout: the brand name and the
93
+ single-source :data:`~induscode.workspace.VERSION`."""
94
+ sys.stdout.write(f"{BRAND.name} {VERSION}\n")
95
+
96
+
97
+ async def _drain_closables(ctx: BootContext) -> None:
98
+ """Drain every teardown callback, latest-registered first, swallowing
99
+ individual failures so one bad closer cannot mask the run's exit code or
100
+ strand the rest."""
101
+ for close in reversed(list(ctx.closables)):
102
+ try:
103
+ result = close()
104
+ if inspect.isawaitable(result):
105
+ await result
106
+ except Exception:
107
+ # Teardown failures are non-fatal; the exit code already
108
+ # reflects the run.
109
+ pass
110
+
111
+
112
+ async def _run_credential(ctx: BootContext) -> int | None:
113
+ """Route a ``signin`` / ``signout`` credential command, if this
114
+ invocation is one.
115
+
116
+ Hands the raw argv to the launch
117
+ :func:`~induscode.launch.run_credential_command` over a disk-backed vault
118
+ scoped to the resolved workspace. Returns the process exit code when the
119
+ command was handled (zero on success, non-zero with a printed fault
120
+ otherwise), or ``None`` when the leading token was not a credential verb
121
+ so the caller proceeds to a normal launch.
122
+
123
+ The OAuth adapter registry is primed explicitly here (never at import
124
+ time) so the OAuth-capable sign-in routes are available.
125
+ """
126
+ register_built_in_oauth_providers()
127
+ result = await run_credential_command(
128
+ list(ctx.argv),
129
+ vault=create_auth_vault(ctx.workspace.auth_path),
130
+ profile_dir=str(ctx.workspace.profile_dir),
131
+ )
132
+ if not result.handled:
133
+ return None
134
+ if result.fault is not None:
135
+ sys.stderr.write(f"{format_credential_fault(result.fault)}\n")
136
+ return _EXIT_CREDENTIAL_FAULT
137
+ return _EXIT_OK
138
+
139
+
140
+ async def _run_package(ctx: BootContext) -> int | None:
141
+ """Route an ``install`` / ``remove`` / ``update`` / ``list`` / ``config``
142
+ package command, if this invocation is one.
143
+
144
+ Hands the raw argv to the launch
145
+ :func:`~induscode.launch.run_package_command` over a settings store
146
+ resolved from the workspace, so the configured extension-package sources
147
+ persist through the same two-tier store the rest of the app reads.
148
+ Returns the process exit code when the command was handled, or ``None``
149
+ when the leading token was not a package subcommand.
150
+ """
151
+ result = await run_package_command(
152
+ list(ctx.argv),
153
+ store=PreferenceStore.from_workspace(ctx.workspace, ctx.invocation.cwd),
154
+ )
155
+ if not result.handled:
156
+ return None
157
+ return result.code if result.code is not None else _EXIT_OK
158
+
159
+
160
+ async def boot(argv: Sequence[str]) -> int:
161
+ """Boot the agent: resolve everything, dispatch to the chosen runner, and
162
+ return the process exit code.
163
+
164
+ :param argv: the arguments the launch was invoked with, already sliced
165
+ (no interpreter / script path)
166
+ :returns: the exit code the process should adopt
167
+ """
168
+ seeded = _seed_context(argv)
169
+
170
+ # The credential command owns the invocation when the leading token is a
171
+ # signin / signout verb; it falls through (handled: False) otherwise.
172
+ credential = await _run_credential(seeded)
173
+ if credential is not None:
174
+ return credential
175
+
176
+ # The package command owns the invocation when the leading token is an
177
+ # install / remove / update / list / config verb; it falls through
178
+ # otherwise.
179
+ package = await _run_package(seeded)
180
+ if package is not None:
181
+ return package
182
+
183
+ # Meta requests short-circuit before any stage runs or any directory is
184
+ # touched.
185
+ if wants_help(seeded.invocation):
186
+ _render_help()
187
+ return _EXIT_OK
188
+ if wants_version(seeded.invocation):
189
+ _render_version()
190
+ return _EXIT_OK
191
+ # `--list-models [filter]` prints the catalog and exits — no session, no
192
+ # directory side effects — mirroring `--help` / `--version`.
193
+ if seeded.invocation.list_models:
194
+ filter = seeded.invocation.list_models_filter
195
+ print_model_catalog(
196
+ None,
197
+ CatalogFilter(search=filter) if filter is not None else CatalogFilter(),
198
+ )
199
+ return _EXIT_OK
200
+
201
+ ctx = await run_stages(seeded)
202
+ try:
203
+ runner = select_runner(ctx.invocation)
204
+ return await runner.run(ctx)
205
+ except Exception as error: # noqa: BLE001 — expected failures become exit 1
206
+ message = str(error) if str(error) else type(error).__name__
207
+ sys.stderr.write(f"{BRAND.name}: {message}\n")
208
+ return _EXIT_FAILURE
209
+ finally:
210
+ await _drain_closables(ctx)
@@ -0,0 +1,223 @@
1
+ """Boot-layer contract — the FROZEN type surface between the OS entry point
2
+ and everything that turns a command line into a running coding agent.
3
+
4
+ Port of TS ``src/boot/contract.ts``. It declares *only* shapes — no behavior,
5
+ no I/O, no literals beyond the narrow string unions that pin the public modes.
6
+ Every other boot module (stage pipeline, invocation projection, runner
7
+ registry, upgrade driver) is written against the names declared here, so this
8
+ file is intentionally small, append-mostly, and stable.
9
+
10
+ Design stance (kept from TS):
11
+
12
+ - One immutable :class:`BootContext` is threaded through an ordered list of
13
+ :class:`Stage` transforms; a stage returns the next context (or the same
14
+ one) and never mutates in place. Successors are produced with
15
+ :func:`dataclasses.replace` (the Python analogue of the TS spread).
16
+ - All branding lives in the single :class:`~induscode.workspace.Brand` record
17
+ and all on-disk paths in the single :class:`~induscode.workspace.Workspace`
18
+ record — both re-exported here so ``induscode.boot`` is a one-stop import
19
+ site, exactly as the TS barrel was.
20
+ - Where the rebuilt framework / app already owns a concept, the
21
+ resolved-resource graph is typed against the published type rather than
22
+ re-declared: framework ``Settings`` for the user-tunable config shape and
23
+ the conductor :class:`~induscode.conductor.ModelCatalog` for the model
24
+ registry (the TS anchors were ``indusagi/shell-app`` ``Settings`` and
25
+ ``indusagi/ai`` ``ModelRegistry``; the port plan routes the catalog through
26
+ the conductor's normalized view).
27
+
28
+ Port notes
29
+ ----------
30
+ - TS interfaces ``Stage`` / ``Runner`` were satisfied by frozen object
31
+ literals; the Python equivalents are frozen dataclasses whose ``apply`` /
32
+ ``accepts`` / ``run`` members are plain callable *fields* (no ``self``
33
+ binding), so a stage/runner row is still data, not a class hierarchy.
34
+ - TS optional-absent fields become explicit ``None`` defaults; readonly
35
+ arrays become tuples. ``closables`` stays a *mutable list* shared across
36
+ ``dataclasses.replace`` successors — accumulation is its purpose.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ from collections.abc import Awaitable, Callable, Mapping
42
+ from dataclasses import dataclass, field
43
+ from typing import Literal, TypeAlias, Union
44
+
45
+ from indusagi.ai import ThinkingLevel
46
+ from indusagi.shell_app import Settings
47
+
48
+ from induscode.conductor import ModelCatalog
49
+ from induscode.workspace import Brand, Workspace
50
+
51
+ __all__ = [
52
+ "BootContext",
53
+ "Brand",
54
+ "Closable",
55
+ "CredentialGraph",
56
+ "Invocation",
57
+ "Runner",
58
+ "RunnerId",
59
+ "Stage",
60
+ "StartupResources",
61
+ "ThinkingLevel",
62
+ "Workspace",
63
+ ]
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Invocation
68
+ # ---------------------------------------------------------------------------
69
+
70
+ #: The three top-level execution modes the boot layer can dispatch to.
71
+ #:
72
+ #: - ``repl`` — interactive terminal session.
73
+ #: - ``oneshot`` — single non-interactive request to stdout (text or JSON).
74
+ #: - ``link`` — headless JSON-RPC link for a driving parent process.
75
+ RunnerId: TypeAlias = Literal["repl", "oneshot", "link"]
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class Invocation:
80
+ """The parsed command line, reduced to what the boot layer needs to choose
81
+ and configure a :class:`Runner`.
82
+
83
+ This is the thin *routing* shape (the launch layer owns the rich parse —
84
+ :class:`induscode.launch.Invocation`); raw un-consumed tokens survive in
85
+ :attr:`rest` and loosely-typed switches in :attr:`flags` so nothing is
86
+ lost between the two. Field names are the mechanical snake_case renames of
87
+ the TS members (``modelId`` → ``model_id``, ``continueLatest`` →
88
+ ``continue_latest``).
89
+ """
90
+
91
+ #: Resolved execution mode.
92
+ mode: RunnerId
93
+ #: Parsed switches, keyed by canonical flag name (values intentionally loose).
94
+ flags: Mapping[str, object]
95
+ #: Positional / pass-through tokens not consumed as flags.
96
+ rest: tuple[str, ...]
97
+ #: First user message / request text, when supplied positionally.
98
+ prompt: str | None = None
99
+ #: Explicit model selector from the command line (``--model`` / ``-m``).
100
+ model_id: str | None = None
101
+ #: Working directory the run is scoped to; ``None`` means the process cwd.
102
+ cwd: str | None = None
103
+ #: Named credential account to authenticate with (``--account``).
104
+ account: str | None = None
105
+ #: Reasoning effort (``--thinking``): off/minimal/low/medium/high/xhigh.
106
+ thinking: str | None = None
107
+ #: Replacement system prompt (``--system``); path-or-literal.
108
+ system: str | None = None
109
+ #: Extra text appended after the system prompt (``--append-system``).
110
+ append_system: str | None = None
111
+ #: Allow-list of built-in tool names (``--tools``); ``None`` = full deck.
112
+ tools: tuple[str, ...] | None = None
113
+ #: Disable every built-in tool (``--no-tools``).
114
+ no_tools: bool = False
115
+ #: External MCP endpoint config paths to attach (``--mcp``).
116
+ mcp: tuple[str, ...] | None = None
117
+ #: Open the resume picker before the session starts (``--resume`` / ``-r``).
118
+ resume: bool | None = None
119
+ #: Auto-resume the most recent session in the cwd (``--continue`` / ``-c``).
120
+ continue_latest: bool | None = None
121
+ #: Print the model catalog and exit (``--list-models``).
122
+ list_models: bool | None = None
123
+ #: Optional substring filter for ``--list-models``.
124
+ list_models_filter: str | None = None
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Resolved startup resources
129
+ # ---------------------------------------------------------------------------
130
+
131
+ #: Placeholder for the resolved per-account credential graph (the TS
132
+ #: forward-declared indexable bag). The concrete multi-account vault lives in
133
+ #: :mod:`induscode.boot.auth_vault`; this stays a loose mapping so the
134
+ #: resource stage can attach it without coupling to the vault module.
135
+ CredentialGraph: TypeAlias = Mapping[str, object]
136
+
137
+
138
+ @dataclass(frozen=True, slots=True)
139
+ class StartupResources:
140
+ """The resolved settings / auth / model-registry graph assembled during
141
+ startup.
142
+
143
+ ``settings`` is typed against the framework's published ``Settings``
144
+ shape (degrading to an empty mapping when the framework piece cannot be
145
+ loaded — the *shape* is what later phases depend on); ``models`` is the
146
+ conductor's normalized :class:`~induscode.conductor.ModelCatalog` (the
147
+ port-plan replacement for the TS framework ``ModelRegistry``)."""
148
+
149
+ #: Merged user settings (framework-owned shape; empty mapping on degrade).
150
+ settings: Settings | Mapping[str, object]
151
+ #: Resolved credentials per account (app-owned placeholder bag).
152
+ auth: CredentialGraph
153
+ #: The resolved model catalog.
154
+ models: ModelCatalog
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Boot context & pipeline
159
+ # ---------------------------------------------------------------------------
160
+
161
+ #: One teardown callback drained on shutdown. May be sync or async; failures
162
+ #: are swallowed by the drain.
163
+ Closable: TypeAlias = Callable[[], Union[Awaitable[None], None]]
164
+
165
+
166
+ @dataclass(frozen=True, slots=True)
167
+ class BootContext:
168
+ """The immutable value threaded through the :class:`Stage` pipeline.
169
+
170
+ Each stage receives a context and returns the next one; treat every field
171
+ as read-only and produce successors via :func:`dataclasses.replace` rather
172
+ than mutating. The resolved-resource graph is ``None`` until the resource
173
+ stage runs. :attr:`closables` accumulates teardown callbacks (open files,
174
+ servers, MCP clients) that the orchestrator drains in reverse on exit —
175
+ it is deliberately a shared mutable list across context successors.
176
+ """
177
+
178
+ #: The process arguments the launch was invoked with (already sliced).
179
+ argv: tuple[str, ...]
180
+ #: Resolved on-disk layout.
181
+ workspace: Workspace
182
+ #: Resolved identity literals.
183
+ brand: Brand
184
+ #: Parsed command line.
185
+ invocation: Invocation
186
+ #: Resolved settings/auth/model graph; absent until the resource stage runs.
187
+ resources: StartupResources | None = None
188
+ #: Teardown callbacks to drain on shutdown (latest-registered first).
189
+ closables: list[Closable] = field(default_factory=list)
190
+
191
+
192
+ @dataclass(frozen=True, slots=True)
193
+ class Stage:
194
+ """One step of the launch pipeline: a named, pure-ish transform over a
195
+ :class:`BootContext`.
196
+
197
+ ``apply`` may return its result synchronously or as an awaitable. It must
198
+ not mutate its input; it returns the next context. ``name`` is used for
199
+ tracing and error attribution. (TS interface → frozen dataclass row whose
200
+ ``apply`` is a plain callable field.)
201
+ """
202
+
203
+ #: Stable identifier for tracing and error messages.
204
+ name: str
205
+ #: Transform the context, yielding its successor.
206
+ apply: Callable[[BootContext], Union[BootContext, Awaitable[BootContext]]]
207
+
208
+
209
+ @dataclass(frozen=True, slots=True)
210
+ class Runner:
211
+ """A terminal execution strategy for one :data:`RunnerId` mode.
212
+
213
+ The runner registry asks each runner whether it ``accepts`` a parsed
214
+ :class:`Invocation`; the first match runs. ``run`` drives the selected
215
+ mode to completion and resolves to the process exit code.
216
+ """
217
+
218
+ #: The mode this runner serves.
219
+ id: RunnerId
220
+ #: Whether this runner handles the given invocation.
221
+ accepts: Callable[[Invocation], bool]
222
+ #: Execute the mode; resolves to the process exit code.
223
+ run: Callable[[BootContext], Awaitable[int]]
@@ -0,0 +1,117 @@
1
+ """Invocation reader for the boot layer — the adapter that drives the full
2
+ declarative launch flag grammar and projects its rich result down onto the
3
+ thin boot :class:`~.contract.Invocation` the runner pipeline routes on.
4
+
5
+ Port of TS ``src/boot/invocation.ts``. The parsing itself is not owned here:
6
+ :func:`induscode.launch.read_invocation` walks the single declarative flag
7
+ table and produces the full launch :class:`~induscode.launch.Invocation`;
8
+ this module's job is purely the *projection* — map the launch
9
+ :data:`~induscode.launch.OutputMode` onto the boot
10
+ :data:`~.contract.RunnerId`, rename the resolved fields the boot layer reads
11
+ (``model`` → ``model_id``, ``positionals`` → ``rest``), and carry the loose
12
+ flag bag through so the meta checks (``--help`` / ``--version``) and the
13
+ oneshot output-shape probe (``json``) keep reading the same canonical keys.
14
+
15
+ - mode: ``text`` → ``repl``, ``json`` → ``oneshot``, ``rpc`` → ``link``.
16
+
17
+ The parse is total and never raises: the launch reader tolerates unknown
18
+ ``--flags`` as switches in the flag bag rather than rejecting them, so
19
+ nothing is silently dropped before a later phase can reinterpret it.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Sequence
25
+
26
+ from induscode.launch import Invocation as LaunchInvocation
27
+ from induscode.launch import OutputMode, read_invocation
28
+
29
+ from .contract import Invocation, RunnerId
30
+
31
+ __all__ = [
32
+ "to_runner_id",
33
+ "tokenize_invocation",
34
+ "wants_help",
35
+ "wants_version",
36
+ ]
37
+
38
+
39
+ def to_runner_id(mode: OutputMode) -> RunnerId:
40
+ """Map a launch :data:`OutputMode` onto the boot :data:`RunnerId` the
41
+ runner registry dispatches on. The two vocabularies are deliberately
42
+ one-to-one:
43
+
44
+ - ``text`` — the interactive terminal session → ``repl``.
45
+ - ``json`` — a single non-interactive request to stdout → ``oneshot``.
46
+ - ``rpc`` — the headless line protocol for a driving parent → ``link``.
47
+ """
48
+ if mode == "rpc":
49
+ return "link"
50
+ if mode == "json":
51
+ return "oneshot"
52
+ return "repl"
53
+
54
+
55
+ def _project_invocation(launch: LaunchInvocation) -> Invocation:
56
+ """Project a fully-parsed launch invocation down onto the thin boot
57
+ :class:`~.contract.Invocation`.
58
+
59
+ The boot layer only needs the routing mode, the prompt, the explicit
60
+ model and cwd, the leftover positionals, and the loose flag bag; the
61
+ richer launch fields (attachments, typed tool roster, …) stay available
62
+ on the launch result for later phases.
63
+ """
64
+ # `--list-models` is a string flag (its value is an optional substring
65
+ # filter), so "present" means set to anything other than False; a
66
+ # non-empty string value becomes the filter.
67
+ list_models_raw = launch.flags.get("list-models")
68
+ list_models = list_models_raw is not None and list_models_raw is not False
69
+ list_models_filter = (
70
+ list_models_raw
71
+ if isinstance(list_models_raw, str) and len(list_models_raw) > 0
72
+ else None
73
+ )
74
+
75
+ return Invocation(
76
+ mode=to_runner_id(launch.mode),
77
+ prompt=launch.prompt,
78
+ model_id=launch.model,
79
+ cwd=launch.cwd,
80
+ account=launch.account,
81
+ thinking=launch.thinking,
82
+ system=launch.system,
83
+ append_system=launch.append_system,
84
+ tools=launch.tools,
85
+ no_tools=bool(launch.no_tools),
86
+ mcp=launch.mcp if len(launch.mcp) > 0 else None,
87
+ resume=True if launch.flags.get("resume") is True else None,
88
+ continue_latest=True if launch.flags.get("continue") is True else None,
89
+ list_models=True if list_models else None,
90
+ list_models_filter=list_models_filter,
91
+ flags=launch.flags,
92
+ rest=tuple(launch.positionals),
93
+ )
94
+
95
+
96
+ def tokenize_invocation(argv: Sequence[str]) -> Invocation:
97
+ """Read a sliced ``argv`` into the boot :class:`~.contract.Invocation`.
98
+
99
+ Delegates the actual grammar to the launch
100
+ :func:`~induscode.launch.read_invocation` (the one declarative flag
101
+ table), then projects its result onto the boot shape — help, parsing, and
102
+ routing share one parser and can never drift.
103
+
104
+ :param argv: the already-sliced argument vector (no interpreter / script
105
+ path)
106
+ """
107
+ return _project_invocation(read_invocation(argv))
108
+
109
+
110
+ def wants_help(inv: Invocation) -> bool:
111
+ """Whether the parsed invocation is asking for the help banner."""
112
+ return inv.flags.get("help") is True
113
+
114
+
115
+ def wants_version(inv: Invocation) -> bool:
116
+ """Whether the parsed invocation is asking for the version string."""
117
+ return inv.flags.get("version") is True
@@ -0,0 +1,42 @@
1
+ """Runners subsystem — public barrel (port of TS ``src/boot/runners``).
2
+
3
+ Surfaces the runner registry and the individual runners. Boot consumers
4
+ dispatch through :func:`select_runner` / :data:`RUNNERS`; the named runners
5
+ are exported for tests and for explicit wiring, along with the session
6
+ assembly helpers (:func:`build_session_conductor`, :func:`session_scope_dir`)
7
+ and the repl console-mount seam M5 plugs into.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .link_runner import link_runner
13
+ from .oneshot_runner import oneshot_runner
14
+ from .registry import RUNNERS, select_runner
15
+ from .repl_runner import ConsoleMount, ReplServices, repl_runner, set_console_mount
16
+ from .session import (
17
+ build_key_resolver,
18
+ build_session_conductor,
19
+ condense_transcript,
20
+ oneshot_prompts,
21
+ prime_provider_env,
22
+ resolve_model_id,
23
+ session_scope_dir,
24
+ )
25
+
26
+ __all__ = [
27
+ "ConsoleMount",
28
+ "RUNNERS",
29
+ "ReplServices",
30
+ "build_key_resolver",
31
+ "build_session_conductor",
32
+ "condense_transcript",
33
+ "link_runner",
34
+ "oneshot_prompts",
35
+ "oneshot_runner",
36
+ "prime_provider_env",
37
+ "repl_runner",
38
+ "resolve_model_id",
39
+ "select_runner",
40
+ "session_scope_dir",
41
+ "set_console_mount",
42
+ ]
@@ -0,0 +1,82 @@
1
+ """Runner: ``link`` — headless JSON-RPC link for a driving parent process.
2
+
3
+ Port of TS ``src/boot/runners/link-runner.ts``. Drives the link channel: it
4
+ assembles a :class:`~induscode.conductor.SessionConductor` for the invocation
5
+ and serves the declarative :data:`~induscode.channels.SESSION_OPS` registry
6
+ over the process stdio pair via
7
+ :func:`~induscode.channels.create_link_server`. Every framed request is
8
+ dispatched through the registry (data-driven, not a command switch) and the
9
+ reply is framed back; the runner resolves the success exit code once the
10
+ inbound stream ends.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import sys
17
+ from collections.abc import AsyncIterator
18
+ from typing import Final
19
+
20
+ from induscode.channels import SESSION_OPS, WritableLine, create_link_server
21
+ from induscode.channels.link import LinkServerIo
22
+
23
+ from ..contract import BootContext, Invocation, Runner
24
+ from .session import build_session_conductor
25
+
26
+ __all__ = ["link_runner"]
27
+
28
+ #: Exit code returned once the link stream ends cleanly.
29
+ _EXIT_OK: Final[int] = 0
30
+
31
+
32
+ class _Stdout:
33
+ """A :class:`~induscode.channels.WritableLine` backed by the process
34
+ standard output stream (flushed per frame — a driving parent reads the
35
+ pipe line by line)."""
36
+
37
+ def write(self, chunk: str) -> object:
38
+ sys.stdout.write(chunk)
39
+ sys.stdout.flush()
40
+ return True
41
+
42
+
43
+ #: The shared stdout sink.
44
+ STDOUT: Final[WritableLine] = _Stdout()
45
+
46
+
47
+ async def _stdin_chunks() -> AsyncIterator[bytes]:
48
+ """The process stdin as an async chunk stream (the
49
+ :data:`~induscode.channels.ReadableChunks` shape). Blocking reads are
50
+ pushed onto the default executor so the event loop — and with it the
51
+ concurrently-dispatched request handlers — keeps running between
52
+ frames."""
53
+ loop = asyncio.get_running_loop()
54
+ stdin = sys.stdin.buffer
55
+ while True:
56
+ chunk = await loop.run_in_executor(None, stdin.read1, 65536)
57
+ if not chunk:
58
+ return
59
+ yield chunk
60
+
61
+
62
+ def _accepts(inv: Invocation) -> bool:
63
+ return inv.mode == "link"
64
+
65
+
66
+ async def _run(ctx: BootContext) -> int:
67
+ """Assemble the conductor, start a link server reading framed requests
68
+ from stdin and writing framed replies to stdout, and resolve the success
69
+ exit code when the inbound stream is exhausted."""
70
+ conductor = await build_session_conductor(ctx)
71
+ server = create_link_server(
72
+ SESSION_OPS,
73
+ conductor,
74
+ LinkServerIo(in_=_stdin_chunks(), out=STDOUT),
75
+ )
76
+ await server.done
77
+ return _EXIT_OK
78
+
79
+
80
+ #: The headless-link runner row: accepts invocations whose resolved mode is
81
+ #: ``link``.
82
+ link_runner: Final[Runner] = Runner(id="link", accepts=_accepts, run=_run)