mycode-cli 0.17.0__py3-none-any.whl → 0.19.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.
mycode_cli/config.py CHANGED
@@ -99,8 +99,6 @@ class WebConfig:
99
99
  @dataclass(frozen=True)
100
100
  class Settings:
101
101
  providers: dict[str, ProviderConfig]
102
- default_provider: str | None
103
- default_model: str | None
104
102
  port: int
105
103
  cwd: str
106
104
  project: str
@@ -305,10 +303,14 @@ def _clean_config_layer(data: Any) -> dict[str, Any]:
305
303
 
306
304
  out: dict[str, Any] = {}
307
305
 
308
- if data.get("default") is not None:
309
- default = _validate_default_config(data["default"])
310
- if default:
311
- out["default"] = default
306
+ compact_threshold = data.get("compact_threshold")
307
+ if compact_threshold is False:
308
+ out["compact_threshold"] = False
309
+ elif compact_threshold is not None:
310
+ parsed = None if isinstance(compact_threshold, bool) else parse_compact_threshold(compact_threshold)
311
+ if parsed is None:
312
+ raise ValueError("compact_threshold must be a number in [0, 1] or false")
313
+ out["compact_threshold"] = parsed
312
314
 
313
315
  if data.get("permission") is not None:
314
316
  out["permission"] = _validate_permission_config(data["permission"])
@@ -345,30 +347,6 @@ def _optional_config_string(raw: dict[str, Any], key: str, label: str) -> str |
345
347
  return value.strip() or None
346
348
 
347
349
 
348
- def _validate_default_config(raw: Any) -> dict[str, Any]:
349
- if not isinstance(raw, dict):
350
- raise ValueError("default must be an object")
351
-
352
- out: dict[str, Any] = {}
353
- for key in ("provider", "model"):
354
- value = _optional_config_string(raw, key, f"default.{key}")
355
- if value:
356
- out[key] = value
357
-
358
- compact_threshold = raw.get("compact_threshold")
359
- if compact_threshold is False:
360
- out["compact_threshold"] = False
361
- elif compact_threshold is not None:
362
- if isinstance(compact_threshold, bool):
363
- raise ValueError("default.compact_threshold must be a number in [0, 1] or false")
364
- parsed = parse_compact_threshold(compact_threshold)
365
- if parsed is None:
366
- raise ValueError("default.compact_threshold must be a number in [0, 1] or false")
367
- out["compact_threshold"] = parsed
368
-
369
- return out
370
-
371
-
372
350
  def _validate_permission_config(raw: Any) -> PermissionLevel | dict[str, Any]:
373
351
  if isinstance(raw, str):
374
352
  return normalize_permission_level(raw)
@@ -498,8 +476,6 @@ def get_settings(cwd: str | None = None) -> Settings:
498
476
  resolved_project = str(resolve_project(resolved_cwd))
499
477
 
500
478
  raw_providers: dict[str, dict[str, Any]] = {}
501
- default_provider: str | None = None
502
- default_model: str | None = None
503
479
  compact_threshold: float | None = None
504
480
  permission = PermissionConfig()
505
481
  raw_web: dict[str, Any] = {}
@@ -534,13 +510,8 @@ def get_settings(cwd: str | None = None) -> Settings:
534
510
 
535
511
  raw_providers[name] = merged
536
512
 
537
- default = data.get("default", {})
538
- if "provider" in default:
539
- default_provider = default["provider"]
540
- if "model" in default:
541
- default_model = default["model"]
542
- if "compact_threshold" in default:
543
- compact_threshold = float(default["compact_threshold"])
513
+ if "compact_threshold" in data:
514
+ compact_threshold = float(data["compact_threshold"])
544
515
 
545
516
  if "permission" in data:
546
517
  permission = parse_permission(data.get("permission"), permission)
@@ -561,8 +532,6 @@ def get_settings(cwd: str | None = None) -> Settings:
561
532
 
562
533
  return Settings(
563
534
  providers=_build_providers(raw_providers),
564
- default_provider=default_provider,
565
- default_model=default_model,
566
535
  compact_threshold=compact_threshold,
567
536
  permission=permission,
568
537
  web=_build_web_config(raw_web),
@@ -691,44 +660,35 @@ def resolve_provider(
691
660
  api_key: str | None = None,
692
661
  api_base: str | None = None,
693
662
  ) -> ResolvedProvider:
694
- """Resolve provider, model, api_key, and api_base from settings and overrides."""
695
-
696
- selected_name = (provider_name or settings.default_provider or "").strip()
697
- if selected_name:
698
- try:
699
- return _resolve_provider_runtime(
700
- settings,
701
- selected_name=selected_name,
702
- model=model,
703
- api_key=api_key,
704
- api_base=api_base,
663
+ """Resolve provider, model, api_key, and api_base from settings and overrides.
664
+
665
+ Without a provider name, the first available provider is used.
666
+ """
667
+
668
+ selected_name = (provider_name or "").strip()
669
+ if not selected_name:
670
+ refs = _available_provider_references(settings)
671
+ if not refs:
672
+ env_names = list(
673
+ dict.fromkeys(
674
+ env_name
675
+ for provider_id in list_env_discoverable_providers()
676
+ for env_name in provider_env_api_key_names(provider_id)
677
+ )
705
678
  )
706
- except ValueError:
707
- # A caller-supplied provider_name must fail loudly rather than silently fall back.
708
- if provider_name:
709
- raise
710
-
711
- refs = _available_provider_references(settings)
712
- if refs:
713
- return _resolve_provider_runtime(
714
- settings,
715
- selected_name=refs[0],
716
- model=model,
717
- api_key=api_key,
718
- api_base=api_base,
719
- )
679
+ checked = ", ".join(env_names) or "<api key env>"
680
+ raise ValueError(
681
+ "no available providers found; set one of the supported API key env vars "
682
+ + f"({checked}) or configure a provider in ~/.mycode/config.json or a project .mycode/config.json"
683
+ )
684
+ selected_name = refs[0]
720
685
 
721
- env_names = list(
722
- dict.fromkeys(
723
- env_name
724
- for provider_id in list_env_discoverable_providers()
725
- for env_name in provider_env_api_key_names(provider_id)
726
- )
727
- )
728
- checked = ", ".join(env_names) or "<api key env>"
729
- raise ValueError(
730
- "no available providers found; set one of the supported API key env vars "
731
- + f"({checked}) or configure a provider in ~/.mycode/config.json or a project .mycode/config.json"
686
+ return _resolve_provider_runtime(
687
+ settings,
688
+ selected_name=selected_name,
689
+ model=model,
690
+ api_key=api_key,
691
+ api_base=api_base,
732
692
  )
733
693
 
734
694
 
@@ -744,41 +704,32 @@ def resolve_provider_choices(settings: Settings) -> list[ResolvedProvider]:
744
704
  return choices
745
705
 
746
706
 
747
- def _available_provider_references(settings: Settings) -> list[str]:
748
- """Return usable provider names with the configured default first."""
749
-
750
- available: list[str] = []
751
- seen: set[str] = set()
752
- configured_types_with_credentials: set[str] = set()
707
+ def provider_models(settings: Settings, provider: ResolvedProvider) -> list[str]:
708
+ """Return a resolved provider's selectable models: configured ones, else the adapter defaults."""
753
709
 
754
- def add(name: str | None) -> None:
755
- cleaned = (name or "").strip()
756
- if not cleaned or cleaned in seen:
757
- return
710
+ provider_config = settings.providers.get(provider.provider_name or "")
711
+ if provider_config and provider_config.models:
712
+ return list(provider_config.models)
713
+ return list(provider_default_models(provider.provider)) or [provider.model]
758
714
 
759
- provider_config = settings.providers.get(cleaned)
760
- provider_type = provider_config.type if provider_config else cleaned
761
- if not is_supported_provider(provider_type):
762
- return
763
715
 
764
- if provider_config:
765
- if not provider_is_available(provider_config):
766
- return
767
- configured_types_with_credentials.add(provider_type)
768
- elif not provider_api_key_from_env(provider_type):
769
- return
770
-
771
- seen.add(cleaned)
772
- available.append(cleaned)
773
-
774
- add(settings.default_provider)
716
+ def _available_provider_references(settings: Settings) -> list[str]:
717
+ """Return usable provider names: configured providers in order, then env-discovered built-ins."""
775
718
 
776
- for name in settings.providers:
777
- add(name)
719
+ available: list[str] = []
720
+ configured_types: set[str] = set()
721
+ for name, config in settings.providers.items():
722
+ if is_supported_provider(config.type) and provider_is_available(config):
723
+ available.append(name)
724
+ configured_types.add(config.type)
778
725
 
779
726
  for provider_id in list_env_discoverable_providers():
780
- if provider_id not in configured_types_with_credentials:
781
- add(provider_id)
727
+ if (
728
+ provider_id not in configured_types
729
+ and provider_id not in settings.providers
730
+ and provider_api_key_from_env(provider_id)
731
+ ):
732
+ available.append(provider_id)
782
733
 
783
734
  return available
784
735
 
@@ -805,8 +756,6 @@ def _resolve_provider_runtime(
805
756
  resolved_model = requested_model
806
757
  elif provider_config and provider_config.models:
807
758
  resolved_model = next(iter(provider_config.models))
808
- elif selected_name == settings.default_provider and (settings.default_model or "").strip():
809
- resolved_model = str(settings.default_model).strip()
810
759
  else:
811
760
  defaults = provider_default_models(provider_type)
812
761
  if not defaults:
mycode_cli/main.py CHANGED
@@ -5,9 +5,9 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import os
7
7
  import sys
8
- from contextlib import aclosing, suppress
8
+ from contextlib import aclosing
9
9
  from dataclasses import dataclass, replace
10
- from typing import TYPE_CHECKING, Annotated, Any, Literal
10
+ from typing import Annotated, Any, Literal
11
11
  from uuid import uuid4
12
12
 
13
13
  import typer
@@ -21,17 +21,14 @@ from mycode_cli.config import (
21
21
  get_settings,
22
22
  normalize_reasoning_effort,
23
23
  parse_permission,
24
- resolve_provider,
25
24
  resolve_sessions_dir,
26
25
  )
27
26
  from mycode_cli.permissions import PERMISSION_DENIED_BY_USER_OUTPUT, PERMISSION_DENIED_OUTPUT
28
27
  from mycode_cli.sessions import SessionStore
28
+ from mycode_cli.state import load_state, resolve_remembered_provider
29
29
 
30
30
  from .runtime import build_agent
31
31
 
32
- if TYPE_CHECKING:
33
- from .tui.render import TerminalView
34
-
35
32
  app = typer.Typer(add_completion=False, pretty_exceptions_enable=False)
36
33
  session_app = typer.Typer(help="Session management")
37
34
  app.add_typer(session_app, name="session")
@@ -140,7 +137,6 @@ async def run_noninteractive(agent: Agent, message: str, *, store: SessionStore,
140
137
  @dataclass
141
138
  class _BootstrapContext:
142
139
  store: SessionStore
143
- view: TerminalView
144
140
  settings: Settings
145
141
  resolved_provider: ResolvedProvider
146
142
  resolved_session: ResolvedSession
@@ -159,21 +155,20 @@ def _bootstrap(
159
155
  ) -> _BootstrapContext:
160
156
  """Shared setup for the chat and run commands."""
161
157
 
162
- from .tui.render import TerminalView
163
-
164
158
  if session and continue_last:
165
159
  raise typer.BadParameter("--session and --continue are mutually exclusive")
166
160
 
167
161
  cwd = os.path.abspath(os.getcwd())
168
162
  store = SessionStore(data_dir=resolve_sessions_dir())
169
- view = TerminalView()
170
163
  try:
171
164
  settings = get_settings(cwd)
172
165
  if permission is not None:
173
166
  settings = replace(settings, permission=parse_permission(permission, settings.permission))
174
- resolved_provider = resolve_provider(settings, provider_name=provider, model=model)
175
- resolved_effort = normalize_reasoning_effort(reasoning_effort)
176
- if resolved_effort is not None:
167
+ state = load_state()
168
+ resolved_provider = resolve_remembered_provider(settings, state, provider_name=provider, model=model)
169
+ if reasoning_effort is None:
170
+ resolved_effort = state.effort_for(resolved_provider)
171
+ elif (resolved_effort := normalize_reasoning_effort(reasoning_effort)) is not None:
177
172
  if not resolved_provider.supports_reasoning_effort:
178
173
  raise ValueError(f"provider {resolved_provider.provider!r} does not support reasoning effort")
179
174
  if resolved_effort not in resolved_provider.reasoning_efforts:
@@ -182,6 +177,7 @@ def _bootstrap(
182
177
  f"reasoning effort {resolved_effort!r} is not supported by model {resolved_provider.model!r}; "
183
178
  + f"supported efforts: {supported}"
184
179
  )
180
+ resolved_provider = replace(resolved_provider, reasoning_effort=resolved_effort)
185
181
  resolved_session = asyncio.run(
186
182
  resolve_session(
187
183
  store=store,
@@ -191,7 +187,9 @@ def _bootstrap(
191
187
  )
192
188
  )
193
189
  except ValueError as exc:
194
- view.console.print(f"[red]{exc}[/red]")
190
+ from .tui.render import console, error_line
191
+
192
+ console.print(error_line(str(exc)))
195
193
  raise SystemExit(1) from exc
196
194
 
197
195
  agent = build_agent(
@@ -202,12 +200,9 @@ def _bootstrap(
202
200
  session_id=resolved_session.session_id,
203
201
  max_turns=max_turns,
204
202
  )
205
- if reasoning_effort is not None:
206
- agent.reasoning_effort = resolved_effort
207
203
 
208
204
  return _BootstrapContext(
209
205
  store=store,
210
- view=view,
211
206
  settings=settings,
212
207
  resolved_provider=resolved_provider,
213
208
  resolved_session=resolved_session,
@@ -266,22 +261,11 @@ def chat(
266
261
  session_id=setup.resolved_session.session_id,
267
262
  provider_name=setup.resolved_provider.provider_name,
268
263
  reasoning_efforts=setup.resolved_provider.reasoning_efforts,
269
- view=setup.view,
270
- )
271
-
272
- setup.view.print_header(
273
- provider=setup.resolved_provider.provider,
274
- model=setup.resolved_provider.model,
275
264
  session=setup.resolved_session.session,
276
265
  mode=setup.resolved_session.mode,
277
- message_count=len(setup.resolved_session.messages),
278
- reasoning_effort=setup.agent.reasoning_effort,
266
+ messages=setup.resolved_session.messages,
279
267
  )
280
- if setup.resolved_session.mode == "resumed":
281
- setup.view.print_history_preview(setup.resolved_session.messages)
282
-
283
- with suppress(KeyboardInterrupt):
284
- asyncio.run(terminal_chat.run())
268
+ asyncio.run(terminal_chat.run())
285
269
 
286
270
 
287
271
  @app.command()
@@ -355,15 +339,15 @@ def session_list(
355
339
  ) -> None:
356
340
  """List saved sessions."""
357
341
 
358
- from .tui.render import TerminalView
342
+ from .tui.render import console, session_list_table
359
343
 
360
344
  cwd = os.path.abspath(os.getcwd())
361
345
  store = SessionStore(data_dir=resolve_sessions_dir())
362
- view = TerminalView()
363
346
 
364
347
  sessions = asyncio.run(store.list_sessions(cwd=None if all_workspaces else cwd))
365
348
  heading = "all sessions" if all_workspaces else f"sessions for {cwd}"
366
- view.print_session_list(sessions, include_cwd=all_workspaces, heading=heading)
349
+ for renderable in session_list_table(sessions, include_cwd=all_workspaces, heading=heading):
350
+ console.print(renderable)
367
351
 
368
352
 
369
353
  def main() -> None:
mycode_cli/runtime.py CHANGED
@@ -5,26 +5,21 @@ from __future__ import annotations
5
5
  from mycode.agent import Agent
6
6
  from mycode_cli.config import ResolvedProvider, Settings
7
7
  from mycode_cli.permissions import ToolReviewCallback, build_permission_hooks
8
- from mycode_cli.sessions import SessionStore, sum_session_cost
8
+ from mycode_cli.sessions import SessionStore, SessionTotals, sum_session_totals
9
9
  from mycode_cli.system_prompt import build_system_prompt
10
10
  from mycode_cli.tools import DEFAULT_TOOLS
11
11
  from mycode_cli.web_tools import build_web_tools
12
12
  from mycode_cli.workspace import CliDeps
13
13
 
14
14
 
15
- def sum_known_costs(*costs: float | None) -> float | None:
16
- known = [cost for cost in costs if cost is not None]
17
- return sum(known) if known else None
15
+ async def load_session_totals(store: SessionStore, session_id: str) -> SessionTotals:
16
+ """Load the session's cumulative usage and USD cost from its raw JSONL timeline.
18
17
 
19
-
20
- async def load_session_cost(store: SessionStore, session_id: str) -> float | None:
21
- """Load the session's cumulative USD cost from its raw JSONL timeline.
22
-
23
- Counts persisted costs from tool loops, compact summaries, and turns
24
- discarded by rewind. Records without a cost are skipped.
18
+ Counts tool loops, compact summaries, and turns discarded by rewind.
19
+ Records without usage or cost contribute nothing to that total.
25
20
  """
26
21
 
27
- return sum_session_cost(await store.load_raw_messages(session_id))
22
+ return sum_session_totals(await store.load_raw_messages(session_id))
28
23
 
29
24
 
30
25
  def build_agent(
@@ -21,7 +21,7 @@ from mycode.attachments import (
21
21
  detect_document_mime_type,
22
22
  detect_image_mime_type,
23
23
  )
24
- from mycode.compact import has_compactable_history
24
+ from mycode.compact import DEFAULT_COMPACT_THRESHOLD, has_compactable_history
25
25
  from mycode.messages import (
26
26
  ConversationMessage,
27
27
  build_message,
@@ -30,29 +30,32 @@ from mycode.messages import (
30
30
  image_block,
31
31
  text_block,
32
32
  )
33
- from mycode.providers import provider_default_models
33
+ from mycode.models import ModelMetadata
34
34
  from mycode_cli.config import (
35
35
  ResolvedProvider,
36
36
  get_settings,
37
37
  normalize_reasoning_effort,
38
+ provider_models,
38
39
  resolve_configured_model_metadata,
39
40
  resolve_provider,
40
41
  resolve_provider_choices,
41
42
  )
42
43
  from mycode_cli.permissions import ToolReviewDecision, ToolReviewRequest
43
- from mycode_cli.runtime import build_agent, load_session_cost
44
+ from mycode_cli.runtime import build_agent, load_session_totals
44
45
  from mycode_cli.server.deps import RunManagerDep, StoreDep, resolve_workspace_cwd
45
- from mycode_cli.server.run_manager import ActiveRunError
46
+ from mycode_cli.server.run_manager import ActiveRunError, RunAgent
46
47
  from mycode_cli.server.schemas import (
47
48
  CancelRunResponse,
48
49
  ChatRequest,
49
50
  ChatResponse,
50
51
  CompactRequest,
51
- CompactResponse,
52
52
  DecideRequest,
53
+ PendingInputRequest,
53
54
  RunInfo,
55
+ RunResponse,
54
56
  StatusResponse,
55
57
  StreamEvent,
58
+ UserInputRequest,
56
59
  )
57
60
  from mycode_cli.system_prompt import build_skill_snapshot_blocks, discover_slash_skills
58
61
  from mycode_cli.workspace import resolve_path
@@ -86,7 +89,7 @@ def _read_workspace_text_attachment(rel_path: str, *, name: str | None, cwd: str
86
89
  raise HTTPException(status_code=400, detail=str(exc)) from exc
87
90
 
88
91
 
89
- async def _build_user_message(chat: ChatRequest, cwd: str) -> ConversationMessage:
92
+ async def _build_user_message(chat: UserInputRequest, cwd: str) -> ConversationMessage:
90
93
  if not chat.input:
91
94
  text = str(chat.message or "").strip()
92
95
  snapshots = await asyncio.to_thread(build_skill_snapshot_blocks, text, cwd)
@@ -143,6 +146,14 @@ async def _build_user_message(chat: ChatRequest, cwd: str) -> ConversationMessag
143
146
  return build_message("user", blocks)
144
147
 
145
148
 
149
+ def _check_input_support(message: ConversationMessage, model: ModelMetadata | RunAgent) -> None:
150
+ content_types = {b.get("type") for b in (message.get("content") or []) if isinstance(b, dict)}
151
+ if "image" in content_types and not model.supports_image_input:
152
+ raise HTTPException(status_code=400, detail="current model does not support image input")
153
+ if "document" in content_types and not model.supports_pdf_input:
154
+ raise HTTPException(status_code=400, detail="current model does not support PDF input")
155
+
156
+
146
157
  def _validate_rewind_request(
147
158
  *,
148
159
  session: dict[str, Any] | None,
@@ -192,11 +203,7 @@ async def chat(chat: ChatRequest, store: StoreDep, runs: RunManagerDep) -> ChatR
192
203
  model=resolved.model,
193
204
  model_config=model_config,
194
205
  )
195
- content_types = {b.get("type") for b in (user_message.get("content") or []) if isinstance(b, dict)}
196
- if "image" in content_types and not model_meta.supports_image_input:
197
- raise HTTPException(status_code=400, detail="current model does not support image input")
198
- if "document" in content_types and not model_meta.supports_pdf_input:
199
- raise HTTPException(status_code=400, detail="current model does not support PDF input")
206
+ _check_input_support(user_message, model_meta)
200
207
 
201
208
  reasoning_effort = resolved.reasoning_effort
202
209
  if "reasoning_effort" in chat.model_fields_set:
@@ -274,10 +281,11 @@ async def chat(chat: ChatRequest, store: StoreDep, runs: RunManagerDep) -> ChatR
274
281
  try:
275
282
  run = await runs.start_run(
276
283
  session_id=session_id,
284
+ cwd=cwd,
277
285
  user_message=user_message,
278
286
  base_messages=agent.messages,
279
287
  agent=agent,
280
- session_cost_base=await load_session_cost(store, session_id),
288
+ session_base=await load_session_totals(store, session_id),
281
289
  )
282
290
  except ActiveRunError as exc:
283
291
  existing = await runs.get_run(exc.run_id)
@@ -339,7 +347,7 @@ async def compact_session(
339
347
  body: CompactRequest,
340
348
  store: StoreDep,
341
349
  runs: RunManagerDep,
342
- ) -> CompactResponse:
350
+ ) -> RunResponse:
343
351
  """Start a compact run: summarize the session and append one compact marker."""
344
352
 
345
353
  async with runs.session_operation(session_id):
@@ -372,9 +380,10 @@ async def compact_session(
372
380
  try:
373
381
  run = await runs.start_compact(
374
382
  session_id=session_id,
383
+ cwd=cwd,
375
384
  base_messages=agent.messages,
376
385
  agent=agent,
377
- session_cost_base=data["session_cost"],
386
+ session_base=data["totals"],
378
387
  on_complete=store.touch,
379
388
  )
380
389
  except ActiveRunError as exc:
@@ -384,7 +393,86 @@ async def compact_session(
384
393
  detail["run"] = existing.info()
385
394
  raise HTTPException(status_code=409, detail=detail) from exc
386
395
 
387
- return CompactResponse(run=RunInfo.model_validate(run))
396
+ return RunResponse(run=RunInfo.model_validate(run))
397
+
398
+
399
+ @router.post("/runs/{run_id}/steer")
400
+ async def steer_run(
401
+ run_id: Annotated[str, PathParam(min_length=1)], body: PendingInputRequest, runs: RunManagerDep
402
+ ) -> RunResponse:
403
+ """Hand a user message to the running chat for its next step boundary."""
404
+
405
+ state = await runs.get_run(run_id)
406
+ if state is None:
407
+ raise HTTPException(status_code=404, detail="run not found")
408
+ message = await _build_user_message(body, state.cwd)
409
+ message["meta"] = {"input_id": body.input_id}
410
+ _check_input_support(message, state.agent)
411
+ if not state.agent.steer(message):
412
+ raise HTTPException(
413
+ status_code=409,
414
+ detail={"message": "run is not accepting steers", "run": state.info()},
415
+ )
416
+ return RunResponse(run=RunInfo.model_validate(state.info()))
417
+
418
+
419
+ @router.post("/sessions/{session_id}/queue")
420
+ async def queue_message(
421
+ session_id: Annotated[str, PathParam(min_length=1)],
422
+ body: PendingInputRequest,
423
+ store: StoreDep,
424
+ runs: RunManagerDep,
425
+ ) -> RunResponse:
426
+ """Queue a user message as the next turn of the session's running chat."""
427
+
428
+ state = await runs.get_active_run(session_id)
429
+ if state is None:
430
+ raise HTTPException(status_code=409, detail={"message": "session has no running chat to queue on"})
431
+ message = await _build_user_message(body, state.cwd)
432
+ message["meta"] = {"input_id": body.input_id}
433
+ _check_input_support(message, state.agent)
434
+ if not await runs.enqueue(state, message):
435
+ raise HTTPException(status_code=409, detail={"message": "session has no running chat to queue on"})
436
+ await store.record_user_turn(
437
+ session_id,
438
+ cwd=state.cwd,
439
+ text=flatten_message_text(message, include_thinking=False),
440
+ )
441
+ return RunResponse(run=RunInfo.model_validate(state.info()))
442
+
443
+
444
+ @router.delete("/sessions/{session_id}/queue/{input_id}")
445
+ async def remove_queued_message(
446
+ session_id: Annotated[str, PathParam(min_length=1)],
447
+ input_id: Annotated[str, PathParam(min_length=1)],
448
+ runs: RunManagerDep,
449
+ ) -> RunResponse:
450
+ """Remove a queued message that has not been delivered."""
451
+
452
+ run = await runs.remove_queued(session_id, input_id)
453
+ if run is None:
454
+ raise HTTPException(status_code=404, detail="queued message not found")
455
+ return RunResponse(run=RunInfo.model_validate(run))
456
+
457
+
458
+ @router.post("/sessions/{session_id}/queue/{input_id}/steer")
459
+ async def steer_queued_message(
460
+ session_id: Annotated[str, PathParam(min_length=1)],
461
+ input_id: Annotated[str, PathParam(min_length=1)],
462
+ runs: RunManagerDep,
463
+ ) -> RunResponse:
464
+ """Move a queued message into the current turn, keeping it as built."""
465
+
466
+ state = await runs.get_active_run(session_id)
467
+ moved = None if state is None else await runs.steer_queued(state, input_id)
468
+ if state is None or moved is None:
469
+ raise HTTPException(status_code=404, detail="queued message not found")
470
+ if not moved:
471
+ raise HTTPException(
472
+ status_code=409,
473
+ detail={"message": "run is not accepting steers", "run": state.info()},
474
+ )
475
+ return RunResponse(run=RunInfo.model_validate(state.info()))
388
476
 
389
477
 
390
478
  @router.get("/config")
@@ -402,13 +490,7 @@ async def get_config(cwd: Annotated[str | None, Query()] = None) -> dict[str, An
402
490
  providers_info: dict[str, Any] = {}
403
491
  for provider in resolve_provider_choices(settings):
404
492
  provider_config = settings.providers.get(provider.provider_name or "")
405
- models = (
406
- list(provider_config.models)
407
- if provider_config and provider_config.models
408
- else list(provider_default_models(provider.provider))
409
- )
410
- if not models:
411
- models = [provider.model]
493
+ models = provider_models(settings, provider)
412
494
 
413
495
  info: dict[str, Any] = {
414
496
  "name": provider.provider_name,
@@ -460,6 +542,10 @@ async def get_config(cwd: Annotated[str | None, Query()] = None) -> dict[str, An
460
542
  "cwd_exists": os.path.isdir(resolved_cwd),
461
543
  "project": settings.project,
462
544
  "config_paths": settings.config_paths,
545
+ # Effective for this cwd; 0 disables automatic compaction.
546
+ "compact_threshold": (
547
+ settings.compact_threshold if settings.compact_threshold is not None else DEFAULT_COMPACT_THRESHOLD
548
+ ),
463
549
  "skills": [{"name": skill.name, "description": skill.description} for skill in skills],
464
550
  "setup_error": setup_error,
465
551
  }