oc_chat
Loading...
Searching...
No Matches
main.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/main.py
4
5"""
6Main entry point for the OC Chat REPL — wiring shell only.
7
8Purpose
9-------
10Parse argv, initialise diagnostics, select a presenter, run the
11storage migration, drive the startup session selection menu, build
12an :class:`~oc_chat.core.agent.Agent` and a slash-command
13:class:`~oc_chat.core.commands.Context`, and run the read-eval loop
14that ferries user input into ``agent.run_turn(...)`` and slash
15commands into :func:`dispatch`. All the per-turn ReAct logic, tool
16dispatch, registry construction, startup wizard, CHANGELOG updates,
17and presentation formatting now live in their own modules; this
18file is the orchestration shell that wires them together.
19
20Responsibilities
21----------------
22- :func:`main` — the wiring sequence above.
23- Slash-command loop body: dispatch slash commands, otherwise
24 drive ``agent.run_turn(user_input)``; on ``/goal`` completion,
25 trigger :func:`append_session_log` against the agent's session.
26
27The decomposition lives at:
28- :mod:`oc_chat.core.react_loop` (G.6.A) — ``run_turn`` + tool dispatch.
29- :mod:`oc_chat.core.tool_registry` (G.6.A) — ``build_tool_registry``.
30- :mod:`oc_chat.core.startup` (G.6.B) — ``startup_session_prompt``.
31- :mod:`oc_chat.core.session_log` (G.6.B) — ``append_session_log``.
32- :mod:`oc_ui.terminal.formatting` (G.6.B) — presentation formatters.
33- :mod:`oc_chat.cli` (G.6.C) — argparse, presenter selection,
34 terminal clear.
35- :mod:`oc_chat.core._version` (G.6.C) — version-string resolver.
36- :mod:`oc_chat.core.agent` (G.6.C) — :class:`Agent` and
37 :class:`AgentConfig`, the runtime building block this shell wires.
38
39Diagnostics
40-----------
41Domain: OC_CHAT.MAIN
42Levels:
43 L2 — REPL lifecycle events.
44 L3 — semantic details.
45 L4 — deep tracing (currently unused).
46
47Contracts
48---------
49- ``oc_diagnostics.init(intercept_streams=False,
50 intercept_exceptions=False)`` runs once at the top of ``main()``.
51 Stream intercept is disabled so the REPL keeps clean stdout/stderr.
52
53Dependencies
54------------
55- oc_diagnostics (structured debug logging via _dbg)
56- oc_chat.cli (argparse, presenter selection, terminal clear)
57- oc_chat.core.agent (Agent + AgentConfig — runtime building block)
58- oc_chat.core.session_log (post-goal SESSION_LOG.md writer)
59- oc_chat.core.commands (slash-command dispatch)
60- oc_chat.core.persistence (migrate_legacy_layout)
61- oc_chat.core.provider_plugin (ProviderPlugin ABC and registry singleton)
62- oc_chat.core.startup (startup_session_prompt — interactive bootstrap)
63- oc_chat.core._version (resolve_version — banner/CHANGELOG)
64- providers.deepseek (DeepSeek provider plugin — default backend)
65- oc_ui.terminal.input (PromptDriver, TurnStats, prompt_prefix builder)
66- oc_chat.core.config_validation (validate_runtime_config and format_config_issue - the startup configuration check and its messages)
67- oc_chat.core.session (ChatSession, TokenStats and the metadata and system-prompt helpers - rebuilding a session for the crash auto-reload)
68- oc_chat.core.session_transition (IterationStatsSink and the turn-phase marks - the process-owned stats sink and the deferred Ctrl+C)
69- oc_chat.tools.ask_user (bind_interaction - binds the ask_user tool to the live prompt driver)
70- providers.ollama (Ollama provider plugin - registered on import)
71- providers.openai_compat (the seven OCON lane providers - registered on import)
72- oc_chat.core.batch_runner (lazy; run_batch_turn - the headless --input / --output turn)
73- oc_chat.core.cancellation (lazy; signal_tool_cancel - stops in-flight tools on the second Ctrl+C)
74- oc_chat.core.deployment_shape (lazy; check_deployment_shape and DeploymentShapeError - refuses a sandbox that overlaps an importable root)
75- oc_chat.core.endpoint_registry (lazy; EndpointRegistry - restores the session's last-used endpoint)
76- oc_chat.core.event_bootstrap (lazy; attach_default_writer - the NDJSON event writer on the default bus)
77- oc_chat.core.external_content_gate (lazy; blast_radius_banner - the permissive profile's startup banner)
78- oc_chat.core.generation_options (lazy; GenerationOptions - restores the session's persisted generation options)
79- oc_chat.core.lane_switch (lazy; switch_lane - applies a lane chosen in the opening menu to the Agent)
80- oc_chat.core.lane_tree (lazy; serving_host_of - names the host that served a turn)
81- oc_chat.core.presenter_events (lazy; attach_presenter_bridge - renders the approval and sub-agent events in the terminal)
82- oc_chat.core.prompt_layers (lazy; _git_branch - the branch in the startup anchor banner)
83- oc_chat.core.risk_profile (lazy; resolve_risk_profile - the active risk profile for the deployment-shape check)
84- oc_chat.core.session_mode (lazy; the --auto launch flag's switch into auto mode)
85- oc_chat.core.settings (lazy; init_runtime_settings - resolves the composition mode once from the settings file and the CLI)
86- oc_chat.tools.filesystem (lazy; _sandbox_root and _sandbox_root_info - the sandbox root for the anchor banner and the deployment-shape check)
87- oc_ui.presenter_base (lazy; INTERRUPT_NOTICES - the stderr fallback for a Ctrl+C notice)
88
89"""
90
91from oc_diagnostics import init as _diagnostics_init, _dbg
92import os
93import signal
94import sys
95
96from oc_chat.cli import build_arg_parser, clear_terminal, select_presenter
97from oc_chat.core._version import __version__, resolve_version
98
99# Plan 233 (P2): the module-level ``EnhancedPresenter`` import that used
100# to sit here was REDUNDANT — nothing in this module referenced the name.
101# It also pulled ``rich`` transitively (enhanced_presenter -> rich_presenter)
102# at import time, defeating cli.py's ImportError-to-plain fallback. The one
103# place that builds an EnhancedPresenter, ``cli.select_presenter``, already
104# imports it lazily inside the branch that owns that fallback.
105from oc_chat.core.agent import Agent, AgentConfig
106from oc_chat.tools import ask_user
107from oc_chat.core.session_log import append_session_log
108from oc_chat.core.commands import (
109 Context, EFFECT_UNKNOWN, HANDLED_NO_TURN, HANDLED_TURN_QUEUED,
110 INTERNAL_FAILURE, dispatch, refresh_presenter_bindings,
111)
113 IterationStatsSink, consume_pending_sigint, mark_between_turns, mark_in_turn,
114)
116 format_config_issue,
117 validate_runtime_config,
118)
119from oc_chat.core.persistence import (
120 DEFAULT_SESSIONS_DIR,
121 apply_tail_recovery,
122 detect_incomplete_tail,
123 load_chat_log,
124 load_session_metadata,
125 migrate_legacy_layout,
126 read_crash_signal,
127 write_clean_exit_marker,
128)
129from oc_chat.core import provider_plugin
130from oc_chat.core.session import (
131 ChatSession,
132 TokenStats,
133 VARIABLE_CONTEXT_SENTINEL,
134 apply_session_metadata_fields,
135 ensure_system_includes_tool_reference,
136 _migrate_legacy_system_prompt,
137)
138from oc_chat.core.startup import (
139 _inject_variable_context,
140 migrate_session_model,
141 refresh_variable_context,
142 startup_session_prompt,
143)
144import providers.deepseek # noqa: F401 — import triggers DeepSeekProvider.register()
145import providers.ollama # noqa: F401 — import triggers OllamaProvider.register()
146import providers.openai_compat # noqa: F401 — import registers the seven OCON lane providers (plan 215)
147from oc_ui.terminal.input import PromptDriver, TurnStats
148
149
150_VERSION = resolve_version()
151
152
153# Phase O3e: module-level holder for the currently-active Agent so the
154# SIGINT handler (installed at the top of main()) can find and flip its
155# CancellationToken even though the handler is installed before the
156# Agent is constructed. ``register_active_agent`` is called once just
157# after ``Agent(...)`` construction below.
158_active_agent_ref: dict[str, "object | None"] = {"agent": None}
159
160
161def register_active_agent(agent: "object | None") -> None:
162 """Register *agent* as the SIGINT-flippable target (Phase O3e).
163
164 Public for tests; main() also calls this after building its Agent.
165 """
166 _active_agent_ref["agent"] = agent
167
168
169def _announce_interrupt(agent: "object | None", stage: str) -> None:
170 """Announce a Ctrl+C *stage* through the presenter, falling back to stderr.
171
172 Plan 188 T5. The handler used to write this notice inline, which meant the
173 component that OWNS the terminal — the presenter, possibly mid-``Live`` —
174 had no say in how or where it landed. It now goes through
175 :meth:`~oc_ui.presenter_base.Presenter.show_interrupt_notice`, whose
176 default is the same stderr write, so a presenter that does not override it
177 behaves exactly as before.
178
179 Two properties matter because this runs inside a signal handler:
180
181 * **it never raises** — a broken presenter must not turn Ctrl+C into a
182 crash, so any failure degrades to the stderr notice and the
183 cancellation below still proceeds;
184 * **it stays one short write** — no prompting, no blocking, no
185 renderable pipeline (see the hook's signal-safety contract).
186 """
187 func = "_announce_interrupt"
188 presenter = getattr(agent, "presenter", None) if agent is not None else None
189 if presenter is not None:
190 try:
191 presenter.show_interrupt_notice(stage)
192 return
193 except Exception as exc: # noqa: BLE001 — a signal handler must not raise
194 _dbg("OC_CHAT.MAIN", func,
195 f"presenter notice failed: {type(exc).__name__}: {exc}", 2)
196 from oc_ui.presenter_base import INTERRUPT_NOTICES
197 text = INTERRUPT_NOTICES.get(stage)
198 if text is None:
199 return
200 sys.stderr.write(text)
201 sys.stderr.flush()
202
203
204def _o3_sigint_handler(signum, frame):
205 """Two-stage cooperative Ctrl+C (Phase O3e + TUI-polish).
206
207 During an active turn the Agent owns a ``CancellationToken``:
208
209 * **1st Ctrl+C** → request a cooperative interrupt and NOTHING ELSE
210 (operator decision 2026-08-12): the in-flight tool keeps running, and the
211 turn is asked "Stop the current action? (y/N)" at its next safe
212 checkpoint — which arrives after that tool returns. "n" continues with
213 nothing lost; nothing was cancelled before the answer. A one-line notice
214 says the current tool keeps running and a second press force-stops.
215 * **2nd Ctrl+C** (an interrupt is already pending, or a hard cancel is set)
216 → force: flip the hard-cancel flag, signal the process-wide tool-cancel
217 flag (the ``[cancelled by user]`` / returncode −2 machinery in
218 ``tools/_proc.py`` — so the child dies cleanly, not orphaned), and raise
219 ``KeyboardInterrupt`` so blocking I/O unwinds immediately.
220
221 **Plan 188 T5.** Both notices go through :func:`_announce_interrupt` →
222 the active presenter's ``show_interrupt_notice``, instead of the raw
223 ``stderr`` write this handler used to do inline. The mechanism above is
224 unchanged; what changed is that the component owning the terminal (Rich,
225 possibly mid-``Live``) now chooses how and where the message lands, with
226 the previous stderr write as the base-class default and the fallback.
227
228 Between turns (no active token) Ctrl+C raises ``KeyboardInterrupt`` as before
229 — the prompt's own handler cancels the input line without exiting OC Chat.
230
231 **Transition deferral (plan 152 R09-01).** While a session transition is
232 committing, the interrupt is RECORDED and the handler returns without
233 raising. That is what makes the commit block genuinely uninterruptible:
234 ``dispatch`` is covered by no ``KeyboardInterrupt`` guard, and
235 ``KeyboardInterrupt`` is a ``BaseException`` an ``except Exception`` wrapper
236 misses, so a press landing between two "no-fail" installs would otherwise
237 strand a mixed tuple with no recovery. Repeated presses coalesce to one
238 pending flag; it is consumed exactly once at the next prompt boundary or
239 turn launch.
240 """
241 # FIRST statement, and read LIVE through the getter — importing the flag by
242 # value would snapshot False at import time and defeat the deferral entirely.
243 from oc_chat.core import session_transition as _st
244 if _st.transition_in_progress():
245 _st.note_pending_sigint()
246 return
247
248 agent = _active_agent_ref["agent"]
249 token = getattr(agent, "cancellation", None) if agent is not None else None
250 if token is not None:
251 # The Ctrl+C handler owns the process-wide tool-cancel flag so in-flight
252 # tools (running in worker threads, which cannot be force-killed) stop at
253 # their next poll point.
254 from oc_chat.core.cancellation import signal_tool_cancel
255 if token.interrupt_requested or token.is_cancelled:
256 token.cancel() # 2nd press — hard stop
257 signal_tool_cancel() # ensure tools stop too
258 # Announce BEFORE the raise: the KeyboardInterrupt unwinds blocking
259 # I/O immediately, so a notice after it would never be reached.
260 _announce_interrupt(agent, "force")
261 raise KeyboardInterrupt
262 # 1st press — cooperative request ONLY (operator decision 2026-08-12,
263 # 188-T5 v2): nothing is cancelled until the checkpoint's y/N confirms.
264 # The in-flight tool keeps running — a stray Ctrl+C costs nothing, and
265 # "n" resumes with the tool result intact. The tool-cancel flag now
266 # belongs exclusively to the 2nd press (and to nothing else).
267 token.request_interrupt()
268 # Plan 188 T5: the presenter owns the terminal, so it owns the notice
269 # (default behaviour is the same stderr write this line used to do).
270 _announce_interrupt(agent, "cancel")
271 return
272 raise KeyboardInterrupt
273
274
276 return os.environ.get("OC_CHAT_SESSIONS_DIR", DEFAULT_SESSIONS_DIR)
277
278
280 prompt_driver, prompt_text: str, default: str, *, presenter
281) -> str:
282 """Read one answer at a pre-session prompt; a Ctrl+C asks before deciding.
283
284 Plan 220 P3 / TUI-02 finding 3, at ``main``'s own two pre-session
285 prompts: the crash auto-reload and the incomplete-tail recovery. Both
286 used to fold a cancelled read into *default*, so a Ctrl+C here
287 auto-reloaded a session, or dropped a saved tail, that nobody asked
288 for — and one more press carried the operator into the REPL.
289
290 EOF still yields *default*: a closed stdin is not an operator choice
291 and there is nobody left to confirm with. A cancel is the opposite —
292 somebody is at the keyboard — so it goes to
293 :func:`~oc_chat.core.startup.confirm_quit_or_continue`, which either
294 exits or returns to re-ask this same question.
295 """
296 from oc_chat.core.startup import confirm_quit_or_continue
297
298 while True:
299 try:
300 value = prompt_driver.read_user_input(None, prompt_text=prompt_text)
301 except EOFError:
302 return default
303 except KeyboardInterrupt:
304 confirm_quit_or_continue(presenter, prompt_driver)
305 continue
306 if value is None:
307 # The driver's report of a Ctrl+C at a menu prompt — an
308 # interrupt, not an empty line (see the docstring above).
309 confirm_quit_or_continue(presenter, prompt_driver)
310 continue
311 return value.strip().lower()
312
313
314def _show_serving_host(presenter, client) -> None:
315 """Name the host that actually served the turn — where the lane says so.
316
317 Plan 220 P4 / TUI-02 finding 4. A routed lane picks its upstream per
318 request, so the serving host is knowable only from the response and
319 only after the fact (the plan-215 catalogue's provenance caveat). It is
320 therefore reported here, after the turn, and never claimed in advance
321 at selection — where the model VENDOR is shown instead.
322
323 A lane reports one by leaving a mapping on its provider's
324 ``last_response_report``; a lane that reports nothing renders nothing.
325 Duck-typed exactly as the plan-215 lane panel is, so ``main`` stays
326 free of any import of an optional provider bundle: a bundle that
327 learns to report a serving host starts showing one here with no change
328 to this function.
329 """
330 func = "_show_serving_host"
331 from oc_chat.core.lane_tree import serving_host_of
332
333 provider = getattr(client, "provider", None)
334 host = serving_host_of(getattr(provider, "last_response_report", None))
335 if not host:
336 return
337 lane = getattr(provider, "name", "") or "lane"
338 presenter.show_info(f"[{lane} — served by {host}]")
339 _dbg("OC_CHAT.MAIN", func, f"lane={lane} reported serving host {host!r}", 3)
340
341
343 name: str,
344 presenter,
345 tool_schemas: list | None,
346 sessions_dir: str,
347) -> ChatSession:
348 """Load a named session without entering the interactive picker."""
349 func = "_load_session_by_name_for_startup"
350 meta = load_session_metadata(name, sessions_dir=sessions_dir)
351 session_name = meta.get("session_name") or name
352
353 # Plan 135 §3.5/§4.7: set the persisted model via the constructor — model
354 # writes go through ChatSession.set_model, never a raw attribute assignment.
355 model = meta.get("model")
356 session = (
357 ChatSession(name=session_name, model=model)
358 if model else ChatSession(name=session_name)
359 )
360 session.reasoning_effort = meta.get("reasoning_effort", session.reasoning_effort)
361 session.chat_logs = meta.get("chat_logs", [])
362 session.stats = TokenStats.from_dict(meta.get("stats", {}))
363 session.created = meta.get("created") or (
364 session_name.split("_", 1)[1] if "_" in session_name else "unknown"
365 )
366 session.ui_prefs = dict(meta.get("ui", {}) or {})
367 apply_session_metadata_fields(session, meta)
368 session.dirty = False
369
370 try:
371 log_data = load_chat_log(session_name, sessions_dir=sessions_dir)
372 session.messages = log_data.get("messages", session.messages)
373 except FileNotFoundError as exc:
374 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: {session_name}: {exc}", 3)
375 presenter.show_info(
376 f"[Note: Session '{session_name}' has no chat log on disk. "
377 "Starting with fresh context.]"
378 )
379 except Exception as exc:
380 _dbg(
381 "OC_CHAT.MAIN",
382 func,
383 f"SYSTEM_ERROR: parse {session_name}: {type(exc).__name__}: {exc}",
384 1,
385 override=True,
386 )
387 session.load_failed = True
388 presenter.show_error(
389 f"[Warning: Could not parse chat log for session '{session_name}' "
390 f"({exc}). On-disk chat log will NOT be overwritten on save until "
391 "reload succeeds.]"
392 )
393
394 # Plan 135 §4.3: migrate any legacy embedded-schema messages[0], then ensure
395 # the compact tool reference is present (both idempotent).
396 _migrate_legacy_system_prompt(session, tool_schemas)
397 ensure_system_includes_tool_reference(session, tool_schemas)
398
399 # Plan 135 §3.5: refresh the loaded variable context IN PLACE (preserves all
400 # conversation history); inject only when the saved session predates it.
401 has_varctx = any(
402 m.get("role") == "user"
403 and (m.get("content") or "").startswith(VARIABLE_CONTEXT_SENTINEL)
404 for m in session.messages
405 )
406 if has_varctx:
407 refresh_variable_context(session)
408 else:
409 _inject_variable_context(session)
410
411 presenter.show_info(
412 f"Loaded session '{session_name}' — "
413 f"{session.stats.message_count} messages, "
414 f"{session.stats.tool_call_count} tool calls, "
415 f"{session.stats.total_tokens:,} tokens "
416 f"(model: {session.model})"
417 )
418 return session
419
420
422 agent: Agent,
423 presenter,
424 prompt_driver,
425 sessions_dir: str,
426) -> ChatSession | None:
427 signal_data = read_crash_signal(sessions_dir=sessions_dir)
428 if signal_data is None:
429 return None
430
431 name = signal_data["last_session_name"]
432 last_modified = signal_data["last_modified"]
433 presenter.show_info(
434 "OC Chat may have exited unexpectedly during your last session "
435 f"('{name}', last modified {last_modified})."
436 )
437
438 while True:
439 choice = _read_startup_input(
440 prompt_driver,
441 "Auto-reload that session? [Y/n]: ",
442 "y",
443 presenter=presenter,
444 )
445 if choice in ("", "y", "yes"):
447 name,
448 presenter,
449 agent.tool_schemas,
450 sessions_dir,
451 )
452 if choice in ("n", "no"):
453 return None
454 presenter.show_info("Please enter Y or N.")
455
456
457def _recover_incomplete_session_tail(session: ChatSession, presenter, prompt_driver) -> bool:
458 report = detect_incomplete_tail(session.messages)
459 if report is None:
460 return False
461
462 presenter.show_info("Session appears to have an incomplete saved turn.")
463 presenter.show_info(report["summary"])
464
465 while True:
466 choice = _read_startup_input(
467 prompt_driver,
468 "Recover by [D]ropping tail, [K]eeping tail, or opening [F]resh? [D/k/f]: ",
469 "d",
470 presenter=presenter,
471 )
472 if choice in ("", "d", "drop"):
473 recovery_choice = "drop"
474 break
475 if choice in ("k", "keep"):
476 recovery_choice = "keep"
477 break
478 if choice in ("f", "fresh"):
479 recovery_choice = "fresh"
480 break
481 presenter.show_info("Please enter D, K, or F.")
482
483 before_count = len(session.messages)
484 recovered_messages = apply_tail_recovery(session.messages, recovery_choice)
485 session.messages = recovered_messages
486 if recovery_choice in ("drop", "fresh") and len(recovered_messages) != before_count:
487 session.dirty = True
488
489 if recovery_choice == "drop":
490 removed = before_count - len(recovered_messages)
491 presenter.show_info(f"Session recovered: dropped {removed} incomplete tail message(s).")
492 elif recovery_choice == "fresh":
493 presenter.show_info("Session recovered: opened fresh context for this session.")
494 else:
495 presenter.show_info("Session recovery skipped: incomplete tail kept unchanged.")
496
497 return True
498
499
500def _show_config_validation(presenter, result) -> bool:
501 for issue in result.warnings:
502 presenter.show_info(format_config_issue(issue))
503 for issue in result.errors:
504 presenter.show_error(format_config_issue(issue))
505 return result.ok
506
507
508def _select_presenter_once(args) -> "object":
509 """Pick the presenter exactly once for this :func:`main` invocation.
510
511 Delegates to :func:`oc_chat.cli.select_presenter`, which already
512 honours both ``--ui`` and the legacy ``--enhanced-ui`` flag (the
513 latter via ``getattr(args, "enhanced_ui", False)``). The named
514 indirection makes the single-call invariant explicit at the call
515 site — :func:`main` previously selected the presenter twice (once
516 for config validation, once after) which silently dropped the
517 ``--enhanced-ui`` choice. Keep this helper as the only path that
518 constructs a presenter inside :func:`main`.
519 """
520 return select_presenter(args)
521
522
523def _safe_presenter_close(presenter) -> None:
524 """Call ``presenter.close()`` if present; swallow + log on failure.
525
526 Phase TUI runtime-prep. The future TUI presenter needs reliable
527 teardown so it can restore the ANSI scroll region, leave the
528 alt-screen, and drop its key bindings — even when :func:`main` is
529 exiting via an unhandled exception. Extracted as a helper so:
530
531 1. :func:`main`'s ``finally`` block stays tidy.
532 2. The contract is unit-testable in isolation
533 (``test_main_cleanup.py``).
534
535 ``hasattr`` keeps the older :class:`ConsolePresenter` (which has
536 no ``close()``) compatible. ``try/except Exception`` keeps the
537 finalizer robust when cleanup itself fails mid-exception-handling.
538 """
539 func = "_safe_presenter_close"
540 if not hasattr(presenter, "close"):
541 return
542 try:
543 presenter.close()
544 except Exception as exc:
545 _dbg(
546 "OC_CHAT.MAIN",
547 func,
548 f"SYSTEM_ERROR: presenter.close: {type(exc).__name__}: {exc}",
549 1,
550 override=True,
551 )
552
553
555 """Refuse to enter the interactive REPL with redirected stdin.
556
557 The REPL reads from ``sys.stdin``; if stdin is a regular file (not a
558 TTY) and the user didn't ask for batch mode via ``--input``/``--output``,
559 each newline in that file becomes a separate user prompt and the loop
560 only exits on EOF. Almost always a mistake (e.g. accidentally pointing
561 stdin at ``.oc_chat/launch/agent-*.md`` or another launch artifact).
562
563 Bypasses on any of: stdin is a TTY, ``--input PATH`` was passed, or
564 ``--output PATH`` was passed. See plan 115 ADR-3 — no env-var override.
565 """
566 if not sys.stdin.isatty() and args.input is None and args.output is None:
567 print(
568 "OC_Chat: stdin is not a TTY but no --input was passed. "
569 "To run a prompt from a file, use --input PATH (or --input - for stdin). "
570 "The interactive REPL refuses redirected stdin to prevent accidental "
571 "file-as-prompt loops.",
572 file=sys.stderr,
573 )
574 raise SystemExit(2)
575
576
577def _anchor_banner_line(root: object, source: str, branch: "str | None") -> str:
578 """Compose the one-line pre-menu anchor banner (plan 173 item 2 / F19).
579
580 ``sandbox root: <abs> (<source label>) | branch: <branch or ->``. ASCII-only
581 (F4): the separator is `` | ``, never a non-cp1252 middot, so it never crashes
582 a stock Windows console.
583 """
584 return f"sandbox root: {root} ({source}) | branch: {branch or '-'}"
585
586
587def _emit_startup_anchor_banner(presenter, args) -> None:
588 """Print the pre-menu anchor banner; on a ``--workdir`` vs resolved-root
589 disagreement, warn and require an explicit y/N before the menu (plan 173 / F19).
590
591 F19: three consecutive mis-anchored launches (a resumed-session persisted-root
592 override + an omitted ``--workdir``) preceded the successful one because the
593 anchor block existed only in the model's context, discovered after pasting.
594 This surfaces ``root · source · branch`` to the operator BEFORE the menu.
595 """
596 func = "_emit_startup_anchor_banner"
597 from pathlib import Path
598 from oc_chat.tools.filesystem import _sandbox_root_info
599 from oc_chat.core.prompt_layers import _git_branch
600
601 root, source = _sandbox_root_info()
602 branch = _git_branch(root)
603 presenter.show_info(_anchor_banner_line(root, source, branch))
604
605 workdir = getattr(args, "workdir", None)
606 if not workdir:
607 return
608 try:
609 wd = Path(workdir).expanduser().resolve(strict=False)
610 except (OSError, ValueError):
611 return
612 if wd == root:
613 return
614 presenter.show_info(
615 f"WARNING: --workdir {wd} but the resolved sandbox root is {root} "
616 f"({source}); files under --workdir may resolve elsewhere."
617 )
618 if not sys.stdin.isatty():
619 return
620 try:
621 from oc_ui.terminal.input import read_single_key
622 answer = read_single_key("Continue with this sandbox root? (y/N) ", valid="yn")
623 except (EOFError, KeyboardInterrupt, OSError):
624 answer = ""
625 if answer != "y":
626 _dbg("OC_CHAT.MAIN", func, "operator declined the --workdir/root mismatch", 2)
627 presenter.show_info("Aborted at the anchor check; re-launch with the intended --workdir.")
628 raise SystemExit(2)
629
630
631def main(argv: list[str] | None = None) -> None:
632 """Start the OC Chat REPL.
633
634 *argv* is exposed for testing; defaults to ``sys.argv[1:]`` when None.
635 """
636 func = "main"
637 clear_terminal()
638
639 # Activate oc_diagnostics side effects (loads debug_config.json, enables
640 # _dbg routing). intercept_streams/intercept_exceptions stay off so the
641 # interactive REPL keeps clean stdout/stderr — _dbg() is the only path
642 # that writes diagnostic output.
643 _diagnostics_init(intercept_streams=False, intercept_exceptions=False)
644 _dbg("OC_CHAT.MAIN", func, f"oc_chat v{_VERSION} starting", 2)
645
646 # Phase O3e: install the SIGINT handler before any code that might
647 # build an Agent. The handler reads the module-level _active_agent_ref
648 # holder, so it's safe to install now and register the agent later
649 # (see register_active_agent call after Agent construction below).
650 signal.signal(signal.SIGINT, _o3_sigint_handler)
651
652 args = build_arg_parser().parse_args(argv)
653
654 # Resolve the process composition mode once (settings file default + CLI
655 # override). --option-c is the convenience alias for --mode option-c.
656 from oc_chat.core.settings import init_runtime_settings
657 _cli_mode = "option-c" if getattr(args, "option_c", False) else getattr(args, "mode", None)
658 init_runtime_settings(_cli_mode)
659
660 # M8.0 WU-7 / G-1: the guarded/isolated deployment-shape invariant. Refuse to
661 # start (interactive OR batch) if the write sandbox overlaps a harness importable
662 # root — a write-then-import would be in-process code execution. permissive: no-op.
663 from oc_chat.core.risk_profile import resolve_risk_profile
664 from oc_chat.core.deployment_shape import check_deployment_shape, DeploymentShapeError
665 from oc_chat.tools.filesystem import _sandbox_root
666 _active_profile = resolve_risk_profile()
667 _raw_profile = os.environ.get("OC_CHAT_PROFILE", "").strip()
668 if _raw_profile and _raw_profile.lower() != _active_profile:
669 # An explicitly-set but unrecognized profile silently falls to permissive
670 # (WU-8 spec). Make that downgrade LOUD so a typo (e.g. 'guareed') cannot
671 # silently disable the guarded T2 protections.
672 print(f"\nWARNING: OC_CHAT_PROFILE={_raw_profile!r} is not a recognized risk "
673 f"profile; running '{_active_profile}'. Valid: permissive, guarded, isolated.\n",
674 file=sys.stderr)
675 try:
676 check_deployment_shape(_sandbox_root(), _active_profile)
677 except DeploymentShapeError as _shape_exc:
678 print(f"\nERROR: {_shape_exc}\n", file=sys.stderr)
679 raise SystemExit(2)
680
681 # Plan 283 (SHELL-04): --auto is the launch spelling of /mode auto, and one
682 # of only three ways into the mode (the command, this flag, and the `u` key
683 # at the ask). Set AFTER the profile is resolved, so the refusal below can
684 # tell the operator their flag will not apply here instead of leaving them
685 # to discover it at the first prompt that still appears.
686 if getattr(args, "auto", False):
687 from oc_chat.core import session_mode
688 if session_mode.auto_mode_available():
689 session_mode.set_mode(session_mode.AUTO)
690 else:
691 # OCT-LINT: disable=no_hardcoded_secrets reason="Operator-facing launch warning; high-entropy English prose, not a credential (operator-ruled 2026-09-16)"
692 print(f"\nWARNING: --auto applies only under the permissive risk "
693 f"profile; running '{_active_profile}', so every confirmable "
694 f"call will still reach you.\n", file=sys.stderr)
695
697
698 # ``--no-color`` was already pre-parsed in ``OC_Chat.py`` to set
699 # NO_COLOR before any oc_chat import. We honour the flag here too in
700 # case ``oc_chat.main:main`` is invoked directly (e.g. from tests).
701 if args.no_color and os.getenv("NO_COLOR") is None:
702 os.environ["NO_COLOR"] = "1"
703
704 sessions_dir = _active_sessions_dir()
705
706 # API-key pre-flight — fail fast with a clear message before constructing
707 # Agent (whose ClientInterface would otherwise build with an empty key).
708 provider_name = os.environ.get("OC_CHAT_PROVIDER", "deepseek")
709
710 if args.input is not None or args.output is not None:
711 from oc_chat.core.batch_runner import run_batch_turn
712
713 raise SystemExit(run_batch_turn(
714 session_name=args.session,
715 input_path=args.input,
716 output_path=args.output,
717 provider_name=provider_name,
718 presenter=select_presenter(args),
719 argv=argv,
720 ))
721
722 # Presenter selection — exactly one call per main() invocation.
723 # _select_presenter_once handles --ui plain/rich/enhanced AND the
724 # legacy --enhanced-ui flag in one place; previously main() picked
725 # twice (once before config validation, once after) which dropped
726 # --enhanced-ui silently. Stay disciplined: do not reassign
727 # ``presenter`` below.
728 presenter = _select_presenter_once(args)
729
730 try:
731 provider = provider_plugin.registry.get(provider_name)
732 except KeyError as e:
733 validation = validate_runtime_config(
734 provider_name=provider_name,
735 provider_names=provider_plugin.registry.names(),
736 sessions_dir=sessions_dir,
737 )
738 if not _show_config_validation(presenter, validation):
739 return
740 presenter.show_error(f"ERROR: {e}")
741 return
742
743 validation = validate_runtime_config(
744 provider_name=provider_name,
745 provider_names=provider_plugin.registry.names(),
746 provider=provider,
747 sessions_dir=sessions_dir,
748 )
749 if not _show_config_validation(presenter, validation):
750 return
751
752 # ── One-shot migration: flat layout → per-session-directory layout ────
753 try:
754 mig = migrate_legacy_layout(sessions_dir)
755 if mig["migrated"] or mig["recovered"] or mig["orphans_moved"]:
756 parts = []
757 if mig["migrated"]:
758 parts.append(f"{mig['migrated']} session(s) migrated")
759 if mig["recovered"]:
760 parts.append(f"{mig['recovered']} chat log(s) recovered from backup")
761 if mig["orphans_moved"]:
762 parts.append(f"{mig['orphans_moved']} orphan log(s) moved to legacy/")
763 presenter.show_info("Storage upgrade: " + ", ".join(parts) + ".")
764 if mig["errors"]:
765 presenter.show_error(
766 "Migration warnings: " + "; ".join(mig["errors"][:3])
767 + (f" (+{len(mig['errors']) - 3} more)" if len(mig["errors"]) > 3 else "")
768 )
769 except Exception as exc:
770 # SYSTEM_ERROR — storage migration is best-effort; surface and continue
771 _dbg("OC_CHAT.MAIN", func, f"SYSTEM_ERROR: migration: {type(exc).__name__}: {exc}", 1, override=True)
772 presenter.show_error(f"Storage migration failed: {exc}")
773
774 # ── Build the agent (engine objects + a placeholder session) ──────────
775 # The agent's session is replaced once the startup menu picks/loads one.
776 agent = Agent(
777 config=AgentConfig(provider_name=provider_name),
778 presenter=presenter,
779 )
780 # Phase O3e: tell the SIGINT handler (installed at the top of main())
781 # which Agent owns the in-flight CancellationToken.
783
784 # Build the input driver early so the startup flow can use it too.
785 turn_stats = TurnStats()
786 startup_driver = PromptDriver(None, turn_stats=turn_stats)
787
788 # Plan 173 item 2 (F19): surface the resolved sandbox anchor BEFORE the menu,
789 # and confirm on a --workdir/root disagreement. Banner-only — no turn-control.
790 _emit_startup_anchor_banner(presenter, args)
791
792 # ── Startup session selection ─────────────────────────────────────────
794 agent,
795 presenter,
796 startup_driver,
797 sessions_dir,
798 )
799 if session is None:
800 def _apply_lane_selection(selection):
801 """Plan 217 T4: apply a confirmed lane selection to the Agent.
802
803 ``main`` owns the Agent whose client and model manager the switch
804 rebuilds, so the opening menu delegates the apply step here rather
805 than importing the Agent into ``startup``. Returning the rebuilt
806 pair lets the menu rebind its own locals — otherwise the very next
807 new-session prompt would list the OLD lane's models.
808 """
809 from oc_chat.core.lane_switch import switch_lane
810
811 switch_lane(agent, selection.lane_name, selection.model_id)
812 return agent.model_manager, agent.client
813
814 session = startup_session_prompt(
815 presenter,
816 version=_VERSION,
817 model_manager=agent.model_manager,
818 tool_schemas=agent.tool_schemas,
819 prompt_driver=startup_driver,
820 client=agent.client,
821 lane_switch=_apply_lane_selection,
822 )
823 _recover_incomplete_session_tail(session, presenter, startup_driver)
824 agent.session = session
825
826 # Plan 217 T4: the menu may have switched lanes, which rebuilt the Agent's
827 # client. ``provider`` was resolved BEFORE the menu, so re-read it from the
828 # live client — the endpoint registry below is provider-scoped and must not
829 # be built for the lane the operator just left.
830 provider = agent.client.provider
831
832 # M2.3: attach the NDJSON event writer to the process-wide default bus once
833 # the active session is bound (plan 101 ADR-2). Idempotent and best-effort —
834 # event persistence must never block REPL startup.
835 try:
836 from oc_chat.core.event_bootstrap import attach_default_writer
837 attach_default_writer(session)
838 except Exception as exc:
839 _dbg("OC_CHAT.MAIN", func, f"SYSTEM_ERROR: event writer attach: {type(exc).__name__}: {exc}", 1, override=True)
840
841 # Plan 188 T2: attach the terminal read side of the bus — the approval and
842 # sub-agent lanes the loop already publishes but never rendered. Same
843 # idempotent, best-effort shape as the writer above: a UI subscriber must
844 # never block REPL startup. Interactive only; batch mode stays quiet
845 # (its presenter default is plain for exactly that reason — D3).
846 try:
847 from oc_chat.core.presenter_events import attach_presenter_bridge
848 attach_presenter_bridge(presenter)
849 except Exception as exc:
850 _dbg("OC_CHAT.MAIN", func,
851 f"SYSTEM_ERROR: presenter bridge attach: {type(exc).__name__}: {exc}",
852 1, override=True)
853
854 # Open the per-iteration analytics sink. Plan 152 D4/ADR-4: the sink is
855 # PROCESS-owned, not session-owned. Under session ownership a replacement
856 # silently dropped --stats-file / --no-iteration-stats, the finaliser closed
857 # only whichever session happened to be current (leaking every other), and a
858 # blind append-reopen rewrote the CSV header mid-file. The sink writes the
859 # header only for a NEW or EMPTY path, and every session BORROWS its writer.
860 iteration_stats_sink = None
861 if args.stats_file:
862 try:
863 iteration_stats_sink = IterationStatsSink(
864 args.stats_file, show=not args.no_iteration_stats
865 )
866 except (OSError, TypeError) as e:
867 presenter.show_error(f"Cannot open stats file {args.stats_file}: {e}")
868
869 session.show_iteration_stats = not args.no_iteration_stats
870 if iteration_stats_sink is not None:
871 # Bridge the startup session onto the process sink; every later
872 # transition re-binds the candidate at commit.
873 iteration_stats_sink.bind_to(session)
874 # Plan 135 §4.6: migrate a retired session model id (deepseek-chat/-reasoner)
875 # to its V4 replacement before the model is ensured/used.
876 migrate_session_model(session, agent.model_manager.provider, presenter)
877 agent.model_manager.ensure(session.model)
878
879 ctx = Context(
880 session=session,
881 model_manager=agent.model_manager,
882 tool_registry=agent.tool_registry,
883 presenter=presenter,
884 client=agent.client,
885 tool_schemas=agent.tool_schemas,
886 turn_stats=turn_stats,
887 iteration_stats_sink=iteration_stats_sink,
888 # Plan 217 T4: /lane rebuilds the client and model manager, and
889 # ``Agent.client`` is what run_turn passes — so the switch has to reach
890 # both. The back-reference is how it does, without agent.py knowing
891 # about the picker.
892 agent=agent,
893 )
894
895 # Tell the presenter which session is active so it can pick up
896 # ui_prefs and write artifacts into the right directory, and bind the
897 # realtime status-snapshot provider (plan 147 D1). Optional —
898 # ConsolePresenter doesn't define bind_session.
899 refresh_presenter_bindings(ctx)
900
901 # Restore persisted generation options from session into ctx
902 _gen_opts_dict = getattr(session, "generation_options", None) or {}
903 if _gen_opts_dict:
904 from oc_chat.core.generation_options import GenerationOptions
905 ctx.generation_options = GenerationOptions(**_gen_opts_dict)
906
907 # Phase O2 — build the endpoint registry, overlay env + TOML, and
908 # restore the session's last-used endpoint (best effort; falls back
909 # to the registry's default on failure).
910 try:
911 from pathlib import Path as _Path
912 from oc_chat.core.endpoint_registry import EndpointRegistry as _EndpointRegistry
913 _ep_registry = _EndpointRegistry(provider)
914 _ep_registry.load_env(os.environ)
915 _ep_registry.load_toml(_Path.home() / ".oc_chat" / "endpoints.toml")
916 _saved_ep = getattr(session, "endpoint_name", "") or ""
917 if _saved_ep and _saved_ep in _ep_registry.entries:
918 try:
919 _ep_registry.switch(_saved_ep)
920 ctx.client.rebind_to(_ep_registry.active())
921 except PermissionError as _ep_exc:
922 _dbg("OC_CHAT.MAIN", func, f"saved endpoint blocked: {_ep_exc}", 1, override=True)
923 ctx.endpoint_registry = _ep_registry
924 except Exception as _ep_exc:
925 # SYSTEM_ERROR — endpoint registry init is best-effort; carry on
926 # with no registry (the /endpoint command surfaces a hint).
927 _dbg("OC_CHAT.MAIN", func, f"SYSTEM_ERROR: endpoint registry init: {type(_ep_exc).__name__}: {_ep_exc}", 1, override=True)
928 ctx.endpoint_registry = None
929
930 agent.ctx = ctx
931 agent.turn_stats = turn_stats
932
933 presenter.show_welcome(session)
934
935 # M8.0 F68: the permissive profile shows its blast-radius banner at startup
936 # (present only while the F67 gate is active, AC-V09-6). The external-content
937 # trust set itself is session-scoped and reset by the react-loop per turn
938 # (external_content_gate.bind_active_session).
939 from oc_chat.core.external_content_gate import blast_radius_banner
940 _blast_banner = blast_radius_banner()
941 if _blast_banner:
942 presenter.show_info(f"[security] {_blast_banner}")
943
944 # Re-create the input driver with the real context so commands and
945 # models are available for tab completion.
946 prompt_driver = PromptDriver(ctx, turn_stats=turn_stats)
947 # Plan 217 T4: /lane's drill-down reads its answers through the REPL's own
948 # input backend rather than a bare input(), so history and key handling
949 # behave as they do at every other prompt.
950 ctx.prompt_driver = prompt_driver
951
952 # Plan 163 §Wiring (the orchestrator's merge step): bind the ask_user
953 # bridge to the live driver. Cancels (Ctrl+C / EOF) become None so the
954 # tool's tested "unanswered" contract handles them and the turn continues.
955 def _ask_user_read(text: str) -> str | None:
956 try:
957 return prompt_driver.read_user_input(None, prompt_text=text)
958 except (EOFError, KeyboardInterrupt):
959 return None
960
961 ask_user.bind_interaction(_ask_user_read)
962
963 if prompt_driver.using_prompt_toolkit:
964 presenter.show_info(
965 "[prompt_toolkit enabled — Tab completes commands, \u2191/\u2193 cycles history, "
966 "Alt+Enter inserts newline, Ctrl+P opens command palette]"
967 )
968
969 # Plan 218 T2 — the session-opening rule. Everything below it belongs to
970 # a turn, so the very first prompt echo already sits inside a boundary
971 # instead of floating above the first rule, which the REPL would
972 # otherwise only draw once the user had already pressed Enter.
973 presenter.show_session_start()
974
975 try:
976 while True:
977 # Plan 152 R10-02 — consume a Ctrl+C that the transition critical
978 # section deferred. NEVER re-raise here: this region catches only
979 # EOFError, and the enclosing try has no `except` at all, so a raise
980 # would EXIT OC Chat. Re-prompting is the whole point.
981 if consume_pending_sigint():
982 presenter.show_info("[Ctrl+C — the session change completed; nothing was interrupted]")
983 continue
984
985 try:
986 # D7/D2 — read the CURRENT session, not the startup loop-local.
987 # A slash command may have replaced it, and the prompt driver
988 # stores what it is handed as the session it reports.
989 user_input = prompt_driver.read_user_input(ctx.session)
990 except EOFError:
991 break
992
993 if user_input is None:
994 # Ctrl+C cancelled the current input line — re-prompt.
995 continue
996
997 user_input = user_input.strip()
998 if user_input.lower() in ("/exit", "/quit", "exit", "quit"):
999 break
1000 if not user_input:
1001 continue
1002
1003 result = dispatch(ctx, user_input)
1004
1005 # Plan 152 ADR-7 — map the four kinds dispatch can actually produce.
1006 # The old message-count delta heuristic is GONE: a queued turn now
1007 # requires an explicit token from the handler, so an in-place
1008 # /session load whose restored log ends in a user message can no
1009 # longer fire an unrequested LLM turn.
1010 if result.kind == INTERNAL_FAILURE:
1011 if result.effect_disposition == EFFECT_UNKNOWN:
1012 # Fail CLOSED: the error and its reconciliation path were
1013 # already surfaced by the handler. Continue on the stated
1014 # indeterminate state rather than pretending it succeeded.
1015 _dbg("OC_CHAT.MAIN", func,
1016 "command failed with INDETERMINATE effects", 1, override=True)
1017 continue
1018 if result.kind == HANDLED_NO_TURN:
1019 continue
1020 if result.warning:
1021 presenter.show_info(f"[{result.warning}]")
1022 # A committed queue runs EXACTLY once; a reporting failure is a
1023 # warning, not a cancellation of valid business work.
1024 turn_input = None if result.kind == HANDLED_TURN_QUEUED else user_input
1025
1026 agent.compress_if_needed()
1027
1028 # Re-bind so any /ui or /session change between turns takes effect
1029 # (plan 147 D1). Reads through ctx.session (not the loop-local
1030 # ``session``) — a slash command may have replaced ctx.session.
1031 refresh_presenter_bindings(ctx)
1032
1033 # D7 — snapshot the session the turn will run against. The cancel
1034 # and error handlers below pop from THIS object, not from whatever
1035 # is current when they fire: an intervening replacement would
1036 # otherwise pop a real message off a different session.
1037 turn_session = ctx.session
1038 # Plan 152 Fable R11-04 — a Ctrl+C deferred during the command's own
1039 # commit converts to a cooperative interrupt AT TURN ENTRY, so the
1040 # notice precedes the turn and the committed queue still runs,
1041 # stopping at its first checkpoint.
1042 if consume_pending_sigint():
1043 presenter.show_info("[Ctrl+C — the queued turn will stop at its first checkpoint]")
1044 token = getattr(agent, "cancellation", None)
1045 if token is not None:
1046 token.request_interrupt()
1047
1048 mark_in_turn()
1049 try:
1050 if turn_input is not None:
1051 agent.run_turn(turn_input)
1052 except KeyboardInterrupt:
1053 # Ctrl+C during streaming. Phase A.5a's react_loop handler
1054 # already showed the cancellation summary and recorded
1055 # partial cost; this backstop ensures the REPL never
1056 # crashes — pop the orphaned user message and loop back
1057 # to the prompt.
1058 _dbg("OC_CHAT.MAIN", func, "turn cancelled by user", 2, override=True)
1059 if turn_session.messages and turn_session.messages[-1]["role"] == "user":
1060 turn_session.messages.pop()
1061 except Exception as e:
1062 # SYSTEM_ERROR — surface stream/transport failure and trim the
1063 # orphaned user message so the next turn isn't poisoned.
1064 _dbg("OC_CHAT.MAIN", func,
1065 f"SYSTEM_ERROR: turn: {type(e).__name__}: {e}", 1, override=True)
1066 presenter.show_error(str(e))
1067 if turn_session.messages and turn_session.messages[-1]["role"] == "user":
1068 turn_session.messages.pop()
1069 finally:
1070 # Restore the phase unconditionally: leaving IN_TURN set would
1071 # make every later transition fail the entry lease.
1072 mark_between_turns()
1073
1074 # Plan 220 T4: the host that actually served this turn, if the
1075 # lane reported one. Silent otherwise — never a claim in advance,
1076 # never an invented one after. Reads the LIVE agent.client: a
1077 # /lane switch mid-session replaces it.
1078 _show_serving_host(presenter, agent.client)
1079
1080 # ── After a /goal turn, record it in SESSION_LOG.md if files changed ──
1081 if ctx.in_goal:
1082 if ctx.goal_files_changed:
1083 try:
1084 append_session_log(ctx)
1085 except Exception as e:
1086 # SYSTEM_ERROR — the session-log write must not abort the REPL
1087 _dbg("OC_CHAT.MAIN", func,
1088 f"SYSTEM_ERROR: session_log: {type(e).__name__}: {e}", 1, override=True)
1089 presenter.show_error(f"SESSION_LOG update failed: {e}")
1090 ctx.in_goal = False
1091 ctx.goal_description = ""
1092 ctx.goal_files_changed = []
1093
1094 # ── After a /plan turn, reset plan-mode flags (Phase J) ──
1095 # Planning does not modify source files, so no CHANGELOG
1096 # update — just reset the flags.
1097 if ctx.in_plan:
1098 ctx.in_plan = False
1099 ctx.plan_description = ""
1100 ctx.plan_files_changed = []
1101
1102 finally:
1103 # Plan 152 D4 — close the ONE process-lifetime sink exactly once, rather
1104 # than whichever session's descriptor happened to be current. Sessions
1105 # only ever hold a BORROWED reference and never close it.
1106 if iteration_stats_sink is not None:
1107 iteration_stats_sink.close()
1108 agent.save()
1109 if sys.exc_info()[0] is None:
1110 try:
1111 write_clean_exit_marker(sessions_dir)
1112 except Exception as exc:
1113 _dbg(
1114 "OC_CHAT.MAIN",
1115 func,
1116 f"SYSTEM_ERROR: clean-exit marker: {type(exc).__name__}: {exc}",
1117 1,
1118 override=True,
1119 )
1120 # Phase TUI runtime-prep: call presenter.close() so the future
1121 # TUI presenter can restore the ANSI scroll region / leave the
1122 # alt-screen / drop key bindings even on abnormal exit.
1123 _safe_presenter_close(presenter)
str _read_startup_input(prompt_driver, str prompt_text, str default, *, presenter)
Definition main.py:281
ChatSession|None _maybe_autoreload_crashed_session(Agent agent, presenter, prompt_driver, str sessions_dir)
Definition main.py:426
bool _show_config_validation(presenter, result)
Definition main.py:500
None _safe_presenter_close(presenter)
Definition main.py:523
str _anchor_banner_line(object root, str source, "str | None" branch)
Definition main.py:577
bool _recover_incomplete_session_tail(ChatSession session, presenter, prompt_driver)
Definition main.py:457
None _emit_startup_anchor_banner(presenter, args)
Definition main.py:587
ChatSession _load_session_by_name_for_startup(str name, presenter, list|None tool_schemas, str sessions_dir)
Definition main.py:347
None _assert_stdin_is_tty_or_input_mode(args)
Definition main.py:554
_o3_sigint_handler(signum, frame)
Definition main.py:204
"object" _select_presenter_once(args)
Definition main.py:508
None register_active_agent("object | None" agent)
Definition main.py:161
None _announce_interrupt("object | None" agent, str stage)
Definition main.py:169
None _show_serving_host(presenter, client)
Definition main.py:314
str _active_sessions_dir()
Definition main.py:275