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.
- induscode/__init__.py +56 -0
- induscode/addons/__init__.py +176 -0
- induscode/addons/contract.py +923 -0
- induscode/addons/dispatch/__init__.py +43 -0
- induscode/addons/dispatch/event_dispatcher.py +348 -0
- induscode/addons/dispatch/tool_interceptor.py +349 -0
- induscode/addons/host.py +469 -0
- induscode/addons/loader.py +314 -0
- induscode/addons/manifest.py +232 -0
- induscode/addons/surface.py +199 -0
- induscode/boot/__init__.py +108 -0
- induscode/boot/auth_vault.py +323 -0
- induscode/boot/boot.py +210 -0
- induscode/boot/contract.py +223 -0
- induscode/boot/invocation.py +117 -0
- induscode/boot/runners/__init__.py +42 -0
- induscode/boot/runners/link_runner.py +82 -0
- induscode/boot/runners/oneshot_runner.py +85 -0
- induscode/boot/runners/registry.py +46 -0
- induscode/boot/runners/repl_runner.py +340 -0
- induscode/boot/runners/session.py +549 -0
- induscode/boot/stages.py +198 -0
- induscode/boot/upgrade/__init__.py +36 -0
- induscode/boot/upgrade/apply.py +125 -0
- induscode/boot/upgrade/upgrades.py +136 -0
- induscode/briefing/__init__.py +115 -0
- induscode/briefing/compose.py +414 -0
- induscode/briefing/contract.py +528 -0
- induscode/briefing/macros.py +721 -0
- induscode/briefing/skills.py +417 -0
- induscode/capability_deck/__init__.py +233 -0
- induscode/capability_deck/bridge_ledger/__init__.py +66 -0
- induscode/capability_deck/bridge_ledger/key.py +181 -0
- induscode/capability_deck/bridge_ledger/ledger.py +276 -0
- induscode/capability_deck/bridge_ledger/network.py +336 -0
- induscode/capability_deck/builtin_bridge.py +358 -0
- induscode/capability_deck/cards/__init__.py +116 -0
- induscode/capability_deck/cards/bg_process.py +482 -0
- induscode/capability_deck/cards/memory.py +226 -0
- induscode/capability_deck/cards/saas.py +280 -0
- induscode/capability_deck/cards/task.py +256 -0
- induscode/capability_deck/cards/todo.py +312 -0
- induscode/capability_deck/contract.py +450 -0
- induscode/capability_deck/manifest.py +126 -0
- induscode/capability_deck/provision.py +217 -0
- induscode/channels/__init__.py +146 -0
- induscode/channels/contract.py +585 -0
- induscode/channels/framer.py +132 -0
- induscode/channels/link/__init__.py +50 -0
- induscode/channels/link/dialog.py +246 -0
- induscode/channels/link/driver.py +308 -0
- induscode/channels/link/server.py +217 -0
- induscode/channels/oneshot.py +178 -0
- induscode/channels/ops.py +140 -0
- induscode/channels/session_ops.py +172 -0
- induscode/conductor/__init__.py +240 -0
- induscode/conductor/catalog.py +309 -0
- induscode/conductor/conductor.py +1084 -0
- induscode/conductor/contract.py +1035 -0
- induscode/conductor/matcher.py +291 -0
- induscode/conductor/serialize.py +575 -0
- induscode/conductor/signal_hub.py +382 -0
- induscode/conductor/skill_parse.py +294 -0
- induscode/conductor/transcript_store.py +449 -0
- induscode/console/__init__.py +236 -0
- induscode/console/app.py +1677 -0
- induscode/console/components/__init__.py +62 -0
- induscode/console/components/banner.py +499 -0
- induscode/console/components/banner_sweep.py +188 -0
- induscode/console/components/emblem.py +181 -0
- induscode/console/components/status_bar.py +102 -0
- induscode/console/contract.py +836 -0
- induscode/console/input/__init__.py +107 -0
- induscode/console/input/chord.py +197 -0
- induscode/console/input/dir_reader.py +113 -0
- induscode/console/input/intents.py +258 -0
- induscode/console/input/providers.py +469 -0
- induscode/console/mount.py +137 -0
- induscode/console/overlays/__init__.py +94 -0
- induscode/console/overlays/auth.py +503 -0
- induscode/console/overlays/pickers.py +526 -0
- induscode/console/overlays/router.py +129 -0
- induscode/console/overlays/sessions.py +232 -0
- induscode/console/reducer.py +145 -0
- induscode/console/resume_picker.py +156 -0
- induscode/console/slash_commands/__init__.py +78 -0
- induscode/console/slash_commands/builtins.py +254 -0
- induscode/console/slash_commands/dynamic.py +217 -0
- induscode/console/slash_commands/integrations.py +949 -0
- induscode/console/slash_commands/transcript.py +404 -0
- induscode/console/slash_commands/workbench.py +430 -0
- induscode/console/startup.py +434 -0
- induscode/console/theme/__init__.py +44 -0
- induscode/console/theme/adapter.py +168 -0
- induscode/console/theme/palette.py +128 -0
- induscode/console/theme/resolve.py +123 -0
- induscode/console/theme/tokens.py +185 -0
- induscode/console_slash/__init__.py +111 -0
- induscode/console_slash/contract.py +185 -0
- induscode/console_slash/registry.py +140 -0
- induscode/console_slash/resolve.py +194 -0
- induscode/console_slash/shared.py +172 -0
- induscode/entry.py +108 -0
- induscode/insight/__init__.py +153 -0
- induscode/insight/collector.py +73 -0
- induscode/insight/replay.py +305 -0
- induscode/insight/wrapper.py +1115 -0
- induscode/kit/__init__.py +82 -0
- induscode/kit/clipboard_image.py +215 -0
- induscode/kit/external_editor.py +120 -0
- induscode/kit/image.py +188 -0
- induscode/kit/shell.py +89 -0
- induscode/kit/tool_fetch.py +288 -0
- induscode/launch/__init__.py +224 -0
- induscode/launch/catalog.py +310 -0
- induscode/launch/contract.py +569 -0
- induscode/launch/credentials.py +852 -0
- induscode/launch/invocation/__init__.py +39 -0
- induscode/launch/invocation/attachments.py +281 -0
- induscode/launch/invocation/flags.py +210 -0
- induscode/launch/invocation/read.py +369 -0
- induscode/launch/invocation/usage.py +110 -0
- induscode/launch/oauth.py +808 -0
- induscode/launch/packages.py +299 -0
- induscode/launch/pickers.py +291 -0
- induscode/py.typed +0 -0
- induscode/runtime_bridge/__init__.py +166 -0
- induscode/runtime_bridge/bridges/__init__.py +66 -0
- induscode/runtime_bridge/bridges/_drive.py +268 -0
- induscode/runtime_bridge/bridges/builtins.py +177 -0
- induscode/runtime_bridge/bridges/claude_cli.py +198 -0
- induscode/runtime_bridge/bridges/codex_cli.py +203 -0
- induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
- induscode/runtime_bridge/broker.py +397 -0
- induscode/runtime_bridge/contract.py +734 -0
- induscode/runtime_bridge/sink.py +351 -0
- induscode/sessions/__init__.py +25 -0
- induscode/sessions/contract.py +119 -0
- induscode/sessions/library.py +350 -0
- induscode/settings/__init__.py +47 -0
- induscode/settings/contract.py +313 -0
- induscode/settings/manager.py +268 -0
- induscode/transcript_export/__init__.py +109 -0
- induscode/transcript_export/contract.py +522 -0
- induscode/transcript_export/publish.py +455 -0
- induscode/transcript_export/sgr.py +566 -0
- induscode/transcript_export/template.py +319 -0
- induscode/transcript_export/theme_bridge.py +325 -0
- induscode/window_budget/__init__.py +76 -0
- induscode/window_budget/budget/__init__.py +26 -0
- induscode/window_budget/budget/estimate.py +273 -0
- induscode/window_budget/budget/gate.py +60 -0
- induscode/window_budget/budget/slice.py +145 -0
- induscode/window_budget/condenser.py +170 -0
- induscode/window_budget/contract.py +329 -0
- induscode/window_budget/summarize/__init__.py +33 -0
- induscode/window_budget/summarize/condense.py +212 -0
- induscode/window_budget/summarize/prompt.py +241 -0
- induscode/workspace/__init__.py +30 -0
- induscode/workspace/brand.py +96 -0
- induscode/workspace/locator.py +269 -0
- induscode-0.1.0.dist-info/METADATA +97 -0
- induscode-0.1.0.dist-info/RECORD +167 -0
- induscode-0.1.0.dist-info/WHEEL +4 -0
- induscode-0.1.0.dist-info/entry_points.txt +3 -0
- induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
- 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)
|