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,85 @@
1
+ """Runner: ``oneshot`` — single non-interactive request to stdout.
2
+
3
+ Port of TS ``src/boot/runners/oneshot-runner.ts``. Drives the oneshot
4
+ channel: it assembles a :class:`~induscode.conductor.SessionConductor` for
5
+ the invocation, builds a :class:`~induscode.channels.ChannelContext` over the
6
+ process stdout (clean text, or a streamed NDJSON event log), runs every
7
+ prompt to settlement via :func:`~induscode.channels.run_oneshot`, and
8
+ resolves the channel exit code. The output shape is NDJSON when the
9
+ invocation carries a ``json`` flag, otherwise clean text.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from typing import Final
16
+
17
+ from induscode.channels import (
18
+ ChannelContext,
19
+ OneshotRequest,
20
+ OneshotShape,
21
+ WritableLine,
22
+ inert_dialog,
23
+ ndjson_framer,
24
+ run_oneshot,
25
+ )
26
+
27
+ from ..contract import BootContext, Invocation, Runner
28
+ from .session import build_session_conductor, oneshot_prompts
29
+
30
+ __all__ = ["oneshot_runner"]
31
+
32
+ #: Exit code returned when no request text was supplied to run.
33
+ _EXIT_NO_INPUT: Final[int] = 2
34
+
35
+
36
+ class _Stdout:
37
+ """A :class:`~induscode.channels.WritableLine` backed by the process
38
+ standard output stream (flushed per chunk so a parent capturing the pipe
39
+ sees frames promptly)."""
40
+
41
+ def write(self, chunk: str) -> object:
42
+ sys.stdout.write(chunk)
43
+ sys.stdout.flush()
44
+ return True
45
+
46
+
47
+ #: The shared stdout sink.
48
+ STDOUT: Final[WritableLine] = _Stdout()
49
+
50
+
51
+ def _shape_of(inv: Invocation) -> OneshotShape:
52
+ """Select the output shape: NDJSON when a ``json`` flag is set, else
53
+ clean text."""
54
+ raw = inv.flags.get("json")
55
+ return "ndjson" if raw is True or raw == "true" else "text"
56
+
57
+
58
+ def _accepts(inv: Invocation) -> bool:
59
+ return inv.mode == "oneshot"
60
+
61
+
62
+ async def _run(ctx: BootContext) -> int:
63
+ """Assemble the conductor and channel context, then delegate the run to
64
+ :func:`~induscode.channels.run_oneshot` and return its exit code. With no
65
+ request text it writes a short notice and exits non-zero rather than
66
+ mounting an empty run."""
67
+ prompts = oneshot_prompts(ctx)
68
+ if len(prompts) == 0:
69
+ sys.stderr.write("oneshot: no request text supplied\n")
70
+ return _EXIT_NO_INPUT
71
+
72
+ conductor = await build_session_conductor(ctx)
73
+ channel = ChannelContext(
74
+ conductor=conductor,
75
+ out=STDOUT,
76
+ framer=ndjson_framer,
77
+ dialog=inert_dialog,
78
+ )
79
+ request = OneshotRequest(shape=_shape_of(ctx.invocation), prompts=tuple(prompts))
80
+ return await run_oneshot(channel, request)
81
+
82
+
83
+ #: The one-shot runner row: accepts invocations whose resolved mode is
84
+ #: ``oneshot``.
85
+ oneshot_runner: Final[Runner] = Runner(id="oneshot", accepts=_accepts, run=_run)
@@ -0,0 +1,46 @@
1
+ """Runner registry — the table-driven replacement for a mode ``if/else``
2
+ ladder.
3
+
4
+ Port of TS ``src/boot/runners/registry.ts``. The boot pipeline ends by
5
+ handing the resolved :class:`~..contract.Invocation` to exactly one
6
+ :class:`~..contract.Runner`. Rather than branch on the mode string at the
7
+ call site, dispatch is data: :data:`RUNNERS` lists every available runner in
8
+ priority order, and :func:`select_runner` returns the first whose
9
+ ``accepts`` predicate matches. Adding or reordering modes is a one-line edit
10
+ to the table, never a change to control flow.
11
+
12
+ The interactive REPL is both first in the table and the guaranteed fallback:
13
+ if no runner claims an invocation, :func:`select_runner` returns it, so a
14
+ bare command line lands in the interactive session.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Final
20
+
21
+ from ..contract import Invocation, Runner
22
+ from .link_runner import link_runner
23
+ from .oneshot_runner import oneshot_runner
24
+ from .repl_runner import repl_runner
25
+
26
+ __all__ = ["RUNNERS", "select_runner"]
27
+
28
+
29
+ #: Every runner the boot layer can dispatch to, in match-priority order.
30
+ #: :func:`select_runner` scans this list front-to-back and returns the first
31
+ #: accepting runner; ``repl_runner`` leads the list and also serves as the
32
+ #: default.
33
+ RUNNERS: Final[tuple[Runner, ...]] = (repl_runner, oneshot_runner, link_runner)
34
+
35
+
36
+ def select_runner(inv: Invocation) -> Runner:
37
+ """Choose the runner for a parsed invocation.
38
+
39
+ Returns the first runner in :data:`RUNNERS` that accepts the invocation;
40
+ if none does, falls back to the interactive REPL runner so dispatch is
41
+ total and a runner is always returned.
42
+ """
43
+ for runner in RUNNERS:
44
+ if runner.accepts(inv):
45
+ return runner
46
+ return repl_runner
@@ -0,0 +1,340 @@
1
+ """Runner: ``repl`` — interactive terminal session.
2
+
3
+ Port of TS ``src/boot/runners/repl-runner.ts``. The runner performs
4
+ everything that precedes the console mount, exactly as TS did:
5
+
6
+ 1. assemble the :class:`~induscode.conductor.SessionConductor`;
7
+ 2. honour ``--resume`` / ``--continue`` BEFORE mounting (so the console
8
+ renders the restored transcript from its first frame), degrading to a
9
+ fresh session on any failure;
10
+ 3. assemble the overlay service bundle (:class:`ReplServices` — the
11
+ preference store, the cwd-scoped session library, the credential vault,
12
+ and the launch sign-in helpers);
13
+ 4. hand everything to the **injectable console mount seam**.
14
+
15
+ The mount seam (:data:`ConsoleMount`, swapped via :func:`set_console_mount`)
16
+ defaults to the real M5 Textual console: the default mount projects the
17
+ :class:`ReplServices` bundle onto the console's ``OverlayServices`` shape and
18
+ awaits :func:`induscode.console.mount.mount_console` (imported lazily so the
19
+ boot layer never pays the console import cost on headless paths). The seam
20
+ stays injectable so tests can observe the assembled services or swap in a
21
+ scripted surface.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import sys
28
+ from collections.abc import Awaitable, Callable
29
+ from dataclasses import dataclass
30
+ from datetime import datetime, timezone
31
+ from typing import Any, Final
32
+
33
+ from induscode.conductor import SessionConductor
34
+ from induscode.launch import (
35
+ ResumeRef,
36
+ list_login_providers,
37
+ open_login_url,
38
+ pick_resume_target,
39
+ start_oauth_login,
40
+ )
41
+ from induscode.launch.contract import AuthVault
42
+ from induscode.launch.pickers import ResumeDeps
43
+ from induscode.sessions import SavedSession, SessionLibrary
44
+ from induscode.settings import PreferenceStore
45
+
46
+ from ..auth_vault import create_auth_vault
47
+ from ..contract import BootContext, Invocation, Runner
48
+ from .session import build_session_conductor, session_scope_dir
49
+
50
+ __all__ = [
51
+ "ConsoleMount",
52
+ "ReplServices",
53
+ "repl_runner",
54
+ "set_console_mount",
55
+ "set_resume_deps",
56
+ ]
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Overlay services
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class ReplServices:
66
+ """The service bundle the interactive overlays drive (the TS
67
+ ``OverlayServices``, assembled here so M5's console receives everything
68
+ ready-made).
69
+
70
+ Everything is resolved from the boot context: the preference store and
71
+ session library from the workspace paths (and the run cwd for the
72
+ project tier), the credential vault from the resolved ``auth.json``
73
+ location, and the sign-in directory / OAuth flow from the launch layer.
74
+ The conductor is the live one the console renders.
75
+ """
76
+
77
+ #: The live session this console drives.
78
+ conductor: SessionConductor
79
+ #: Two-tier preference store (global + project tier for the run cwd).
80
+ settings: PreferenceStore
81
+ #: Session catalog scoped to the run cwd's sessions directory.
82
+ sessions: SessionLibrary
83
+ #: The merged sign-in directory lister.
84
+ list_login_providers: Callable[[], Any]
85
+ #: The browser sign-in flow.
86
+ start_oauth_login: Callable[..., Any]
87
+ #: The platform browser launcher.
88
+ open_login_url: Callable[[str], Any]
89
+ #: The disk credential vault.
90
+ vault: AuthVault
91
+
92
+
93
+ def _build_services(ctx: BootContext, conductor: SessionConductor) -> ReplServices:
94
+ """Assemble the :class:`ReplServices` for a boot context and the live
95
+ conductor."""
96
+ cwd = ctx.invocation.cwd if ctx.invocation.cwd is not None else os.getcwd()
97
+ return ReplServices(
98
+ conductor=conductor,
99
+ settings=PreferenceStore.from_workspace(ctx.workspace, cwd),
100
+ sessions=SessionLibrary(
101
+ sessions_dir=session_scope_dir(ctx.workspace.sessions_dir, cwd)
102
+ ),
103
+ list_login_providers=list_login_providers,
104
+ start_oauth_login=start_oauth_login,
105
+ open_login_url=open_login_url,
106
+ vault=create_auth_vault(ctx.workspace.auth_path),
107
+ )
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Console mount seam (M5 plugs in here)
112
+ # ---------------------------------------------------------------------------
113
+
114
+ #: The console mount: given the live conductor, the service bundle, and the
115
+ #: optional initial input, render the interactive surface and resolve the
116
+ #: process exit code once it is dismissed.
117
+ ConsoleMount = Callable[[SessionConductor, ReplServices, "str | None"], Awaitable[int]]
118
+
119
+
120
+ async def _default_console_mount(
121
+ conductor: SessionConductor,
122
+ services: ReplServices,
123
+ initial_input: str | None,
124
+ ) -> int:
125
+ """The default mount: the real M5 Textual console.
126
+
127
+ Projects the boot-layer :class:`ReplServices` bundle onto the console's
128
+ ``OverlayServices`` shape (field-for-field — the bundles were designed to
129
+ line up) and awaits the console mount. Imports are deferred so the boot
130
+ layer never pays the console (Textual) import cost on headless paths.
131
+ """
132
+ from induscode.console.contract import OverlayServices
133
+ from induscode.console.mount import mount_console
134
+
135
+ overlay_services = OverlayServices(
136
+ conductor=conductor,
137
+ settings=services.settings,
138
+ sessions=services.sessions,
139
+ list_login_providers=services.list_login_providers,
140
+ start_oauth_login=services.start_oauth_login,
141
+ open_login_url=services.open_login_url,
142
+ vault=services.vault,
143
+ )
144
+ return await mount_console(conductor, overlay_services, initial_input=initial_input)
145
+
146
+
147
+ #: The active mount. Module-held so a test (or an alternative surface) can
148
+ #: swap in a scripted console without touching the runner logic.
149
+ _console_mount: ConsoleMount = _default_console_mount
150
+
151
+
152
+ def set_console_mount(mount: ConsoleMount | None) -> None:
153
+ """Install the console mount the repl runner drives (``None`` restores
154
+ the real console default). Tests use this seam to observe the assembled
155
+ services or to stub the interactive surface."""
156
+ global _console_mount
157
+ _console_mount = mount if mount is not None else _default_console_mount
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Resume picker seam (the Textual startup picker plugs in here)
162
+ # ---------------------------------------------------------------------------
163
+
164
+
165
+ def _default_resume_deps() -> ResumeDeps:
166
+ """The live :class:`~induscode.launch.pickers.ResumeDeps` for the
167
+ interactive ``--resume`` picker: a real TTY probe plus a ``mount_picker``
168
+ that runs the framework Textual ``StartupSessionPicker`` (the same picker
169
+ the in-console ``/resume`` command reaches). The console module is
170
+ imported lazily so the boot layer never pays the Textual import cost on a
171
+ headless / non-interactive ``--resume`` path — the launch fast-path
172
+ resolves the newest session there without ever touching ``mount_picker``."""
173
+ from induscode.console.resume_picker import default_startup_resume_deps
174
+
175
+ return default_startup_resume_deps()
176
+
177
+
178
+ #: The resume-deps factory the runner injects into ``pick_resume_target``.
179
+ #: Module-held so a test can swap in a fake picker mount (and a fake TTY
180
+ #: probe) without a terminal; ``None`` restores the live Textual default.
181
+ _resume_deps_factory: Callable[[], ResumeDeps] = _default_resume_deps
182
+
183
+
184
+ def set_resume_deps(factory: Callable[[], ResumeDeps] | None) -> None:
185
+ """Install the :class:`~induscode.launch.pickers.ResumeDeps` factory the
186
+ repl runner injects into the ``--resume`` picker (``None`` restores the
187
+ live Textual default). Tests use this seam to drive the picker over a fake
188
+ mount that returns a scripted selection, pinning that the raising launch
189
+ default is never reached on the interactive path."""
190
+ global _resume_deps_factory
191
+ _resume_deps_factory = factory if factory is not None else _default_resume_deps
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Resume handling
196
+ # ---------------------------------------------------------------------------
197
+
198
+
199
+ def _to_resume_ref(row: SavedSession) -> ResumeRef:
200
+ """Project a :class:`~induscode.sessions.SavedSession` catalog row onto
201
+ the framework ``SessionInfo`` the resume picker lists. The picker only
202
+ ever reads ``path`` / ``id`` / ``name`` / ``lastModified`` for display,
203
+ so the conversational counters and message text are filled defensively
204
+ from what the shallow row carries (zero / empty when it was not
205
+ deep-read), keeping the mapping total without re-opening every file."""
206
+ last_modified = row.lastModified if row.lastModified is not None else 0
207
+ stamp = datetime.fromtimestamp(last_modified / 1000, tz=timezone.utc)
208
+ return ResumeRef(
209
+ id=row.id,
210
+ path=row.path,
211
+ cwd="",
212
+ lastModified=int(last_modified),
213
+ created=stamp,
214
+ modified=stamp,
215
+ messageCount=row.messageCount if row.messageCount is not None else 0,
216
+ firstMessage=row.preview if row.preview is not None else "",
217
+ allMessagesText=row.preview if row.preview is not None else "",
218
+ name=row.name,
219
+ size=row.size,
220
+ )
221
+
222
+
223
+ async def _choose_resume_target(
224
+ ctx: BootContext, library: SessionLibrary
225
+ ) -> str | None:
226
+ """Resolve the session id to resume for an invocation, or ``None`` for a
227
+ fresh session.
228
+
229
+ Mirrors how the in-app sessions overlay resumes: it hands
230
+ ``conductor.resume`` the bare session **id** (the filename stem the
231
+ store loads by), never a path. ``--continue`` takes the newest row from
232
+ the library's newest-first list; ``--resume`` runs the shared
233
+ :func:`~induscode.launch.pick_resume_target` picker over the same rows
234
+ and maps the chosen file path back to its originating id. Either flag
235
+ yielding nothing resolves to ``None``.
236
+ """
237
+ inv = ctx.invocation
238
+
239
+ if inv.continue_latest is True:
240
+ # The library lists newest-first, so the head row is the most recent
241
+ # session in the cwd; resume by its bare id exactly as the overlay
242
+ # does.
243
+ rows = await library.list()
244
+ return rows[0].id if len(rows) > 0 else None
245
+
246
+ # `--resume`: list once, project to the picker's ResumeRef shape, and
247
+ # keep a path→id index so the chosen file path can be mapped back to the
248
+ # id the conductor resumes by. The two loaders share the one list (the
249
+ # library is already scoped to the cwd's sessions dir).
250
+ rows = await library.list()
251
+ if len(rows) == 0:
252
+ return None
253
+ refs = [_to_resume_ref(row) for row in rows]
254
+ id_by_path = {row.path: row.id for row in rows}
255
+
256
+ async def load() -> list[ResumeRef]:
257
+ return refs
258
+
259
+ # Inject the Textual-backed resume deps so the interactive picker actually
260
+ # mounts (the launch default's `mount_picker` raises by design — the
261
+ # console runner owns the live mount). The non-TTY fast path inside
262
+ # `pick_resume_target` short-circuits to the newest session before
263
+ # `mount_picker` is ever called, so these deps' picker is reached only on
264
+ # a real terminal.
265
+ outcome = await pick_resume_target(load, load, _resume_deps_factory())
266
+ if outcome.fault is not None:
267
+ cause = outcome.fault.cause
268
+ if isinstance(cause, BaseException):
269
+ raise cause
270
+ raise RuntimeError(outcome.fault.message)
271
+ if outcome.path is None:
272
+ return None
273
+ return id_by_path.get(outcome.path)
274
+
275
+
276
+ async def _apply_resume(ctx: BootContext, conductor: SessionConductor) -> None:
277
+ """Honour ``--resume`` / ``--continue`` before the console mounts.
278
+
279
+ No-op unless one of the flags is set. Picks the target session id, then
280
+ asks the live conductor to resume it. A failure (load, picker mount, or
281
+ resume) degrades to the fresh session the conductor already holds — a
282
+ one-line stderr notice is printed and the launch continues rather than
283
+ crashing. Selecting nothing also falls through to fresh.
284
+ """
285
+ inv = ctx.invocation
286
+ if inv.resume is not True and inv.continue_latest is not True:
287
+ return
288
+
289
+ library = SessionLibrary(
290
+ sessions_dir=session_scope_dir(
291
+ ctx.workspace.sessions_dir,
292
+ inv.cwd if inv.cwd is not None else os.getcwd(),
293
+ )
294
+ )
295
+ try:
296
+ session_id = await _choose_resume_target(ctx, library)
297
+ if session_id is None:
298
+ sys.stderr.write("No prior session to resume; starting a fresh one.\n")
299
+ return
300
+ await conductor.resume(session_id)
301
+ # `conductor.resume` swallows a missing-session load into a typed
302
+ # fault on its signal stream rather than raising; detect that so a
303
+ # stale id still falls back to fresh instead of mounting onto a
304
+ # faulted state.
305
+ state = conductor.snapshot()
306
+ if state.phase == "faulted":
307
+ sys.stderr.write(
308
+ f'Could not resume session "{session_id}"; starting a fresh one.\n'
309
+ )
310
+ except Exception as cause: # noqa: BLE001 — degrade to a fresh session
311
+ detail = str(cause) if str(cause) else type(cause).__name__
312
+ sys.stderr.write(f"Resume failed ({detail}); starting a fresh session.\n")
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # The runner
317
+ # ---------------------------------------------------------------------------
318
+
319
+
320
+ def _accepts(inv: Invocation) -> bool:
321
+ return inv.mode == "repl"
322
+
323
+
324
+ async def _run(ctx: BootContext) -> int:
325
+ """Assemble the conductor, honour ``--resume`` / ``--continue``, and
326
+ mount the (injectable) console."""
327
+ conductor = await build_session_conductor(ctx)
328
+ # Resume a prior session BEFORE wiring services / mounting, so the
329
+ # console renders the restored transcript from its first frame. A resume
330
+ # failure is handled inside `_apply_resume`, which leaves the fresh
331
+ # session in place.
332
+ await _apply_resume(ctx, conductor)
333
+ services = _build_services(ctx, conductor)
334
+ return await _console_mount(conductor, services, ctx.invocation.prompt)
335
+
336
+
337
+ #: The interactive-REPL runner row: matches invocations whose resolved mode
338
+ #: is ``repl``; it is also the registry default, so it is reached for the
339
+ #: empty / interactive command line.
340
+ repl_runner: Final[Runner] = Runner(id="repl", accepts=_accepts, run=_run)