python-codex 0.0.1__py3-none-any.whl → 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. pycodex/__init__.py +139 -2
  2. pycodex/agent.py +290 -0
  3. pycodex/cli.py +641 -0
  4. pycodex/collaboration.py +21 -0
  5. pycodex/context.py +580 -0
  6. pycodex/doctor.py +360 -0
  7. pycodex/model.py +533 -0
  8. pycodex/prompts/collaboration_default.md +11 -0
  9. pycodex/prompts/collaboration_plan.md +128 -0
  10. pycodex/prompts/default_base_instructions.md +275 -0
  11. pycodex/prompts/exec_tools.json +411 -0
  12. pycodex/prompts/models.json +847 -0
  13. pycodex/prompts/permissions/approval_policy/never.md +1 -0
  14. pycodex/prompts/permissions/approval_policy/on_failure.md +1 -0
  15. pycodex/prompts/permissions/approval_policy/on_request.md +57 -0
  16. pycodex/prompts/permissions/approval_policy/on_request_rule_request_permission.md +33 -0
  17. pycodex/prompts/permissions/approval_policy/unless_trusted.md +1 -0
  18. pycodex/prompts/permissions/sandbox_mode/danger_full_access.md +1 -0
  19. pycodex/prompts/permissions/sandbox_mode/read_only.md +1 -0
  20. pycodex/prompts/permissions/sandbox_mode/workspace_write.md +1 -0
  21. pycodex/prompts/subagent_tools.json +163 -0
  22. pycodex/protocol.py +347 -0
  23. pycodex/runtime.py +200 -0
  24. pycodex/runtime_services.py +408 -0
  25. pycodex/tools/__init__.py +58 -0
  26. pycodex/tools/agent_tool_schemas.py +70 -0
  27. pycodex/tools/apply_patch_tool.py +363 -0
  28. pycodex/tools/base_tool.py +168 -0
  29. pycodex/tools/close_agent_tool.py +55 -0
  30. pycodex/tools/code_mode_manager.py +519 -0
  31. pycodex/tools/exec_command_tool.py +96 -0
  32. pycodex/tools/exec_runtime.js +161 -0
  33. pycodex/tools/exec_tool.py +48 -0
  34. pycodex/tools/grep_files_tool.py +150 -0
  35. pycodex/tools/list_dir_tool.py +135 -0
  36. pycodex/tools/read_file_tool.py +217 -0
  37. pycodex/tools/request_permissions_tool.py +95 -0
  38. pycodex/tools/request_user_input_tool.py +167 -0
  39. pycodex/tools/resume_agent_tool.py +56 -0
  40. pycodex/tools/send_input_tool.py +106 -0
  41. pycodex/tools/shell_command_tool.py +107 -0
  42. pycodex/tools/shell_tool.py +112 -0
  43. pycodex/tools/spawn_agent_tool.py +97 -0
  44. pycodex/tools/unified_exec_manager.py +380 -0
  45. pycodex/tools/update_plan_tool.py +79 -0
  46. pycodex/tools/view_image_tool.py +111 -0
  47. pycodex/tools/wait_agent_tool.py +75 -0
  48. pycodex/tools/wait_tool.py +68 -0
  49. pycodex/tools/web_search_tool.py +30 -0
  50. pycodex/tools/write_stdin_tool.py +75 -0
  51. pycodex/utils/__init__.py +40 -0
  52. pycodex/utils/dotenv.py +64 -0
  53. pycodex/utils/get_env.py +218 -0
  54. pycodex/utils/random_ids.py +19 -0
  55. pycodex/utils/visualize.py +978 -0
  56. python_codex-0.1.0.dist-info/METADATA +267 -0
  57. python_codex-0.1.0.dist-info/RECORD +60 -0
  58. python_codex-0.1.0.dist-info/entry_points.txt +2 -0
  59. python_codex-0.1.0.dist-info/licenses/LICENSE +201 -0
  60. python_codex-0.0.1.dist-info/METADATA +0 -30
  61. python_codex-0.0.1.dist-info/RECORD +0 -4
  62. {python_codex-0.0.1.dist-info → python_codex-0.1.0.dist-info}/WHEEL +0 -0
pycodex/cli.py ADDED
@@ -0,0 +1,641 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import argparse
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import sys
9
+ from dataclasses import asdict, replace
10
+ from pathlib import Path
11
+ from typing import Literal, Sequence
12
+
13
+ from .agent import AgentLoop
14
+ from .collaboration import DEFAULT_COLLABORATION_MODE, CollaborationMode
15
+ from .context import ContextManager
16
+ from .model import ResponsesModelClient, ResponsesProviderConfig
17
+ from .protocol import AgentEvent
18
+ from .runtime import AgentRuntime
19
+ from .runtime_services import get_runtime_environment
20
+ from .utils import CliSessionView, load_codex_dotenv
21
+ from responses_server import launch_chat_completion_compat_server
22
+
23
+ EXIT_COMMANDS = {"/exit", "/quit"}
24
+ HISTORY_COMMAND = "/history"
25
+ TITLE_COMMAND = "/title"
26
+ MODEL_COMMAND = "/model"
27
+ QUEUE_COMMAND = "/queue"
28
+ CliSessionMode = Literal["exec", "tui"]
29
+ LOCAL_RESPONSES_SERVER_API_KEY_ENV = "PYCODEX_LOCAL_RESPONSES_SERVER_KEY"
30
+ CLI_ORIGINATOR = "codex-tui"
31
+
32
+
33
+ def configure_loguru() -> None:
34
+ try:
35
+ from loguru import logger
36
+ except ImportError: # pragma: no cover - dependency may be absent in minimal envs
37
+ return
38
+
39
+ logger.remove()
40
+ log_path = os.environ.get("PYCODEX_DEBUG_LOG", "").strip()
41
+ if log_path:
42
+ logger.add(log_path, level="DEBUG")
43
+ return
44
+
45
+ if os.environ.get("PYCODEX_DEBUG_STDERR", "").strip().lower() in {
46
+ "1",
47
+ "true",
48
+ "yes",
49
+ "on",
50
+ }:
51
+ logger.add(sys.stderr, level="DEBUG")
52
+
53
+
54
+ def build_parser() -> argparse.ArgumentParser:
55
+ parser = argparse.ArgumentParser(
56
+ prog="pycodex",
57
+ description="Minimal Codex-style local CLI backed by ~/.codex/config.toml.",
58
+ )
59
+ parser.add_argument(
60
+ "prompt", nargs="*", help="Prompt text. If omitted, read from stdin."
61
+ )
62
+ parser.add_argument(
63
+ "--config",
64
+ default=str(Path.home() / ".codex" / "config.toml"),
65
+ help="Path to Codex config.toml.",
66
+ )
67
+ parser.add_argument(
68
+ "--profile",
69
+ default=None,
70
+ help="Optional profile name from config.toml.",
71
+ )
72
+ parser.add_argument(
73
+ "--vllm-endpoint",
74
+ default=None,
75
+ help=(
76
+ "Optional base URL for a chat-completions-backed vLLM server. "
77
+ "When set, pycodex starts a local responses compat server for this "
78
+ "session and appends /v1 if the path is empty."
79
+ ),
80
+ )
81
+ parser.add_argument(
82
+ "--use-chat-completion",
83
+ default=False,
84
+ action="store_true",
85
+ help=(
86
+ "When set, pycodex starts a local responses compat server for this session."
87
+ ),
88
+ )
89
+ parser.add_argument(
90
+ "--system-prompt",
91
+ default=None,
92
+ help="Optional base instructions override passed to the model.",
93
+ )
94
+ parser.add_argument(
95
+ "--timeout-seconds",
96
+ type=float,
97
+ default=120.0,
98
+ help="HTTP timeout for one model call.",
99
+ )
100
+ parser.add_argument(
101
+ "--json",
102
+ action="store_true",
103
+ help="Print the full TurnResult as JSON.",
104
+ )
105
+ return parser
106
+
107
+
108
+ def should_run_interactive(prompt_parts: Sequence[str], stdin_is_tty: bool) -> bool:
109
+ return not prompt_parts and stdin_is_tty
110
+
111
+
112
+ def resolve_prompt_text(prompt_parts: Sequence[str]) -> str:
113
+ if prompt_parts:
114
+ return " ".join(prompt_parts).strip()
115
+
116
+ if not sys.stdin.isatty():
117
+ prompt_text = sys.stdin.read().strip()
118
+ if prompt_text:
119
+ return prompt_text
120
+
121
+ raise ValueError("prompt is required either as argv text or stdin")
122
+
123
+
124
+ def get_tools(exec_mode: bool = False):
125
+ from .tools import (
126
+ ApplyPatchTool,
127
+ CloseAgentTool,
128
+ CodeModeManager,
129
+ ExecTool,
130
+ ExecCommandTool,
131
+ GrepFilesTool,
132
+ ListDirTool,
133
+ ReadFileTool,
134
+ RequestPermissionsTool,
135
+ RequestUserInputTool,
136
+ ResumeAgentTool,
137
+ Registry,
138
+ SendInputTool,
139
+ ShellCommandTool,
140
+ ShellTool,
141
+ SpawnAgentTool,
142
+ UnifiedExecManager,
143
+ UpdatePlanTool,
144
+ ViewImageTool,
145
+ WaitAgentTool,
146
+ WaitTool,
147
+ WebSearchTool,
148
+ WriteStdinTool,
149
+ )
150
+
151
+ runtime_environment = get_runtime_environment()
152
+ registry = Registry()
153
+ code_mode_manager = CodeModeManager(registry)
154
+ unified_exec_manager = UnifiedExecManager()
155
+ exec_tool = ExecTool(code_mode_manager)
156
+ wait_tool = WaitTool(code_mode_manager)
157
+ web_search_tool = WebSearchTool()
158
+ update_plan_tool = UpdatePlanTool(runtime_environment.plan_store)
159
+ request_user_input_tool = RequestUserInputTool(
160
+ runtime_environment.request_user_input_manager
161
+ )
162
+ request_permissions_tool = RequestPermissionsTool(
163
+ runtime_environment.request_permissions_manager
164
+ )
165
+ spawn_agent_tool = SpawnAgentTool(runtime_environment.subagent_manager)
166
+ send_input_tool = SendInputTool(runtime_environment.subagent_manager)
167
+ resume_agent_tool = ResumeAgentTool(runtime_environment.subagent_manager)
168
+ wait_agent_tool = WaitAgentTool(runtime_environment.subagent_manager)
169
+ close_agent_tool = CloseAgentTool(runtime_environment.subagent_manager)
170
+ apply_patch_tool = ApplyPatchTool()
171
+ shell_tool = ShellTool()
172
+ shell_command_tool = ShellCommandTool()
173
+ exec_command_tool = ExecCommandTool(unified_exec_manager)
174
+ write_stdin_tool = WriteStdinTool(unified_exec_manager)
175
+ grep_files_tool = GrepFilesTool()
176
+ read_file_tool = ReadFileTool()
177
+ list_dir_tool = ListDirTool()
178
+ view_image_tool = ViewImageTool()
179
+ if exec_mode:
180
+ registry.register(exec_command_tool)
181
+ registry.register(write_stdin_tool)
182
+ registry.register(update_plan_tool)
183
+ registry.register(request_user_input_tool)
184
+ registry.register(apply_patch_tool)
185
+ registry.register(web_search_tool)
186
+ registry.register(view_image_tool)
187
+ registry.register(spawn_agent_tool)
188
+ registry.register(send_input_tool)
189
+ registry.register(resume_agent_tool)
190
+ registry.register(wait_agent_tool)
191
+ registry.register(close_agent_tool)
192
+ return registry
193
+
194
+ registry.register(shell_tool)
195
+ registry.register(shell_command_tool)
196
+ registry.register(exec_command_tool)
197
+ registry.register(write_stdin_tool)
198
+ registry.register(exec_tool)
199
+ registry.register(wait_tool)
200
+ registry.register(web_search_tool)
201
+ registry.register(update_plan_tool)
202
+ registry.register(request_user_input_tool)
203
+ registry.register(request_permissions_tool)
204
+ registry.register(spawn_agent_tool)
205
+ registry.register(send_input_tool)
206
+ registry.register(resume_agent_tool)
207
+ registry.register(wait_agent_tool)
208
+ registry.register(close_agent_tool)
209
+ registry.register(apply_patch_tool)
210
+ registry.register(grep_files_tool)
211
+ registry.register(read_file_tool)
212
+ registry.register(list_dir_tool)
213
+ registry.register(view_image_tool)
214
+ return registry
215
+
216
+
217
+ def get_subagent_tools():
218
+ from .tools import (
219
+ ApplyPatchTool,
220
+ ExecCommandTool,
221
+ Registry,
222
+ UnifiedExecManager,
223
+ UpdatePlanTool,
224
+ ViewImageTool,
225
+ WebSearchTool,
226
+ WriteStdinTool,
227
+ )
228
+
229
+ runtime_environment = get_runtime_environment()
230
+ registry = Registry()
231
+ unified_exec_manager = UnifiedExecManager()
232
+ registry.register(ExecCommandTool(unified_exec_manager))
233
+ registry.register(WriteStdinTool(unified_exec_manager))
234
+ registry.register(UpdatePlanTool(runtime_environment.plan_store))
235
+ registry.register(ApplyPatchTool())
236
+ registry.register(WebSearchTool())
237
+ registry.register(ViewImageTool())
238
+ return registry
239
+
240
+
241
+ def build_runtime(
242
+ config_path: str,
243
+ profile: str | None,
244
+ system_prompt: str | None,
245
+ client,
246
+ session_mode: CliSessionMode = "exec",
247
+ collaboration_mode: CollaborationMode = DEFAULT_COLLABORATION_MODE,
248
+ ) -> AgentRuntime:
249
+ use_tui_context = session_mode == "tui"
250
+ context_manager = ContextManager.from_codex_config(
251
+ config_path,
252
+ profile,
253
+ base_instructions_override=system_prompt,
254
+ collaboration_mode=collaboration_mode,
255
+ include_collaboration_instructions=use_tui_context,
256
+ )
257
+ subagent_context_manager = ContextManager.from_codex_config(
258
+ config_path,
259
+ profile,
260
+ base_instructions_override=system_prompt,
261
+ include_collaboration_instructions=False,
262
+ )
263
+ runtime_environment = get_runtime_environment()
264
+ runtime_environment.request_user_input_manager.set_handler(None)
265
+ runtime_environment.request_permissions_manager.set_handler(None)
266
+
267
+ def build_nested_runtime(
268
+ model_override: str | None,
269
+ reasoning_effort_override: str | None,
270
+ initial_history=(),
271
+ session_id: str | None = None,
272
+ ) -> AgentRuntime:
273
+ nested_client = client.with_overrides(
274
+ model_override,
275
+ reasoning_effort_override,
276
+ session_id=session_id,
277
+ openai_subagent="collab_spawn",
278
+ )
279
+ nested_agent = AgentLoop(
280
+ nested_client,
281
+ get_subagent_tools(),
282
+ subagent_context_manager,
283
+ initial_history=tuple(initial_history),
284
+ )
285
+ return AgentRuntime(nested_agent)
286
+
287
+ runtime_environment.configure_runtime_builder(build_nested_runtime)
288
+ return AgentRuntime(AgentLoop(client, get_tools(exec_mode=True), context_manager))
289
+
290
+
291
+ def format_turn_output(result, json_mode: bool) -> str:
292
+ if json_mode:
293
+ return json.dumps(asdict(result), ensure_ascii=False, indent=2)
294
+ return result.output_text or ""
295
+
296
+
297
+ def _build_model_client(
298
+ config_path: str,
299
+ profile: str | None,
300
+ timeout_seconds: float,
301
+ managed_responses_base_url: str | None = None,
302
+ vllm_endpoint: str | None = None,
303
+ use_chat_completion: bool = False,
304
+ ):
305
+ load_codex_dotenv(config_path)
306
+ provider_config = ResponsesProviderConfig.from_codex_config(
307
+ config_path,
308
+ profile,
309
+ )
310
+ url, key_env = provider_config.base_url, provider_config.api_key_env
311
+ if managed_responses_base_url is not None:
312
+ url, key_env = (
313
+ managed_responses_base_url,
314
+ LOCAL_RESPONSES_SERVER_API_KEY_ENV,
315
+ )
316
+ os.environ.setdefault(LOCAL_RESPONSES_SERVER_API_KEY_ENV, "dummy")
317
+ elif vllm_endpoint or use_chat_completion:
318
+ if vllm_endpoint:
319
+ managed_server = launch_chat_completion_compat_server(
320
+ vllm_endpoint,
321
+ model_provider="vllm",
322
+ )
323
+ else:
324
+ managed_server = launch_chat_completion_compat_server(
325
+ provider_config.base_url,
326
+ provider_config.api_key_env,
327
+ model_provider=provider_config.provider_name,
328
+ )
329
+ atexit.register(managed_server.stop)
330
+ url, key_env = (
331
+ managed_server.base_url,
332
+ LOCAL_RESPONSES_SERVER_API_KEY_ENV,
333
+ )
334
+ os.environ.setdefault(LOCAL_RESPONSES_SERVER_API_KEY_ENV, "dummy")
335
+
336
+ provider_config = replace(
337
+ provider_config,
338
+ base_url=url,
339
+ api_key_env=key_env,
340
+ )
341
+ return ResponsesModelClient(
342
+ provider_config,
343
+ timeout_seconds,
344
+ originator=CLI_ORIGINATOR,
345
+ )
346
+
347
+
348
+ async def prompt_request_user_input(
349
+ view: CliSessionView,
350
+ payload: dict[str, object],
351
+ ) -> dict[str, object] | None:
352
+ view.finish_stream()
353
+ view.pause_spinner()
354
+ view.write_line("[request_user_input] waiting for user response")
355
+ answers: dict[str, dict[str, list[str]]] = {}
356
+ try:
357
+ for question in payload.get("questions", []):
358
+ if not isinstance(question, dict):
359
+ continue
360
+ header = str(question.get("header", "")).strip()
361
+ question_text = str(question.get("question", "")).strip()
362
+ question_id = str(question.get("id", "")).strip()
363
+ if header:
364
+ view.write_line(f"[{header}] {question_text}")
365
+ else:
366
+ view.write_line(question_text)
367
+
368
+ options = question.get("options") or []
369
+ if isinstance(options, list):
370
+ for index, option in enumerate(options, start=1):
371
+ if not isinstance(option, dict):
372
+ continue
373
+ label = str(option.get("label", "")).strip()
374
+ description = str(option.get("description", "")).strip()
375
+ view.write_line(f" {index}. {label} - {description}")
376
+ view.write_line(" 0. Other")
377
+
378
+ try:
379
+ raw_answer = await view.prompt_async("answer> ")
380
+ except EOFError:
381
+ return None
382
+ answer_text = raw_answer.strip()
383
+ if not answer_text:
384
+ return None
385
+
386
+ selected_answer = answer_text
387
+ if answer_text.isdigit() and isinstance(options, list):
388
+ choice = int(answer_text)
389
+ if 1 <= choice <= len(options):
390
+ option = options[choice - 1]
391
+ if isinstance(option, dict):
392
+ selected_answer = (
393
+ str(option.get("label", "")).strip() or answer_text
394
+ )
395
+ elif choice == 0:
396
+ try:
397
+ raw_answer = await view.prompt_async("other> ")
398
+ except EOFError:
399
+ return None
400
+ selected_answer = raw_answer.strip()
401
+ if not selected_answer:
402
+ return None
403
+
404
+ answers[question_id] = {"answers": [selected_answer]}
405
+
406
+ return {"answers": answers}
407
+ finally:
408
+ view.resume_spinner()
409
+
410
+
411
+ async def prompt_request_permissions(
412
+ view: CliSessionView,
413
+ payload: dict[str, object],
414
+ ) -> dict[str, object] | None:
415
+ view.finish_stream()
416
+ view.pause_spinner()
417
+ view.write_line("[request_permissions] user approval required")
418
+ reason = payload.get("reason")
419
+ if reason:
420
+ view.write_line(f"Reason: {reason}")
421
+ view.write_line("Requested permissions:")
422
+ view.write_line(
423
+ json.dumps(payload.get("permissions", {}), ensure_ascii=False, indent=2)
424
+ )
425
+ view.write_line("Choose: [n] deny / [t] grant for turn / [s] grant for session")
426
+ try:
427
+ raw_answer = await view.prompt_async("permissions> ")
428
+ except EOFError:
429
+ return None
430
+ finally:
431
+ view.resume_spinner()
432
+
433
+ answer = raw_answer.strip().lower()
434
+ if answer in {"t", "turn", "y", "yes"}:
435
+ return {
436
+ "permissions": payload.get("permissions", {}),
437
+ "scope": "turn",
438
+ }
439
+ if answer in {"s", "session"}:
440
+ return {
441
+ "permissions": payload.get("permissions", {}),
442
+ "scope": "session",
443
+ }
444
+ return {
445
+ "permissions": {},
446
+ "scope": "turn",
447
+ }
448
+
449
+
450
+ async def run_interactive_session(
451
+ runtime: AgentRuntime,
452
+ json_mode: bool,
453
+ ) -> int:
454
+ worker = asyncio.create_task(runtime.run_forever())
455
+ view = CliSessionView()
456
+ model_client = runtime._agent_loop._model_client
457
+ runtime.set_event_handler(view.handle_event)
458
+ pending_turn_tasks: set[asyncio.Task[None]] = set()
459
+ runtime_environment = get_runtime_environment()
460
+ runtime_environment.request_user_input_manager.set_handler(
461
+ lambda payload: prompt_request_user_input(view, payload)
462
+ )
463
+ runtime_environment.request_permissions_manager.set_handler(
464
+ lambda payload: prompt_request_permissions(view, payload)
465
+ )
466
+ view.write_line("pycodex interactive mode. Type /exit to quit.")
467
+ view.write_line("Extra commands: /history, /title, /model")
468
+ try:
469
+ def has_pending_turn_tasks() -> bool:
470
+ pending_turn_tasks.difference_update(
471
+ task for task in tuple(pending_turn_tasks) if task.done()
472
+ )
473
+ return bool(pending_turn_tasks)
474
+
475
+ async def wait_for_turn_result(future) -> None:
476
+ try:
477
+ result = await future
478
+ except Exception as exc: # pragma: no cover - defensive surface
479
+ if str(exc) == "submission interrupted":
480
+ return
481
+ view.show_error(str(exc))
482
+ return
483
+
484
+ if json_mode:
485
+ view.write_line(format_turn_output(result, True))
486
+
487
+ while True:
488
+ try:
489
+ raw_line = await view.poll_prompt("pycodex> ")
490
+ except EOFError:
491
+ break
492
+ if raw_line is None:
493
+ await asyncio.sleep(0.05)
494
+ continue
495
+
496
+ prompt_text = raw_line.strip()
497
+ if not prompt_text:
498
+ continue
499
+ if prompt_text in EXIT_COMMANDS:
500
+ break
501
+ if prompt_text == HISTORY_COMMAND:
502
+ view.show_history()
503
+ continue
504
+ if prompt_text == TITLE_COMMAND:
505
+ view.show_title()
506
+ continue
507
+ if prompt_text.startswith(f"{QUEUE_COMMAND} "):
508
+ queued_text = prompt_text[len(QUEUE_COMMAND) :].strip()
509
+ if not queued_text:
510
+ view.write_line("Usage: /queue <message>")
511
+ continue
512
+ try:
513
+ submission_id, future = await runtime.enqueue_user_turn(
514
+ queued_text, queue="enqueue"
515
+ )
516
+ view.show_steer_queued(submission_id, queued_text)
517
+ turn_task = asyncio.create_task(wait_for_turn_result(future))
518
+ pending_turn_tasks.add(turn_task)
519
+ except Exception as exc: # pragma: no cover - defensive surface
520
+ view.show_error(str(exc))
521
+ continue
522
+ if prompt_text == MODEL_COMMAND:
523
+ view.write_line(
524
+ f"Current model: {getattr(model_client, 'model', None) or 'unavailable'}"
525
+ )
526
+ models = await model_client.list_models()
527
+ view.write_line(f"Available models: {', '.join(models)}")
528
+ continue
529
+ if prompt_text.startswith(f"{MODEL_COMMAND} "):
530
+ if has_pending_turn_tasks():
531
+ view.write_line(
532
+ "Cannot change model while work is running or queued in steer mode."
533
+ )
534
+ continue
535
+ model_name = prompt_text[len(MODEL_COMMAND) :].strip()
536
+ if not model_name:
537
+ view.write_line("Usage: /model <model>")
538
+ continue
539
+
540
+ model_client.model = model_name
541
+ view.write_line(f"Switched model to {model_name}.")
542
+ continue
543
+
544
+ try:
545
+ steered = has_pending_turn_tasks()
546
+ submission_id, future = await runtime.enqueue_user_turn(
547
+ prompt_text,
548
+ queue="steer",
549
+ )
550
+ if steered:
551
+ view.schedule_steer_inserted(submission_id, prompt_text)
552
+ turn_task = asyncio.create_task(wait_for_turn_result(future))
553
+ pending_turn_tasks.add(turn_task)
554
+ continue
555
+ except Exception as exc: # pragma: no cover - defensive surface
556
+ view.show_error(str(exc))
557
+ continue
558
+ finally:
559
+ runtime_environment.request_user_input_manager.set_handler(None)
560
+ runtime_environment.request_permissions_manager.set_handler(None)
561
+ await runtime.shutdown()
562
+ await worker
563
+ if pending_turn_tasks:
564
+ await asyncio.gather(*pending_turn_tasks, return_exceptions=True)
565
+ view.close()
566
+
567
+ return 0
568
+
569
+
570
+ async def run_cli(args: argparse.Namespace) -> int:
571
+ configure_loguru()
572
+ runtime = None
573
+ worker = None
574
+ try:
575
+ client = _build_model_client(
576
+ args.config,
577
+ args.profile,
578
+ args.timeout_seconds,
579
+ vllm_endpoint=args.vllm_endpoint,
580
+ use_chat_completion=args.use_chat_completion,
581
+ )
582
+
583
+ runtime = build_runtime(
584
+ args.config,
585
+ args.profile,
586
+ args.system_prompt,
587
+ client,
588
+ session_mode="tui",
589
+ )
590
+ if should_run_interactive(args.prompt, sys.stdin.isatty()):
591
+ return await run_interactive_session(
592
+ runtime,
593
+ args.json,
594
+ )
595
+ else:
596
+ prompt_text = resolve_prompt_text(args.prompt)
597
+ worker = asyncio.create_task(runtime.run_forever())
598
+ result = await runtime.submit_user_turn(prompt_text)
599
+ print(format_turn_output(result, args.json))
600
+ return 0
601
+ except Exception as exc:
602
+ print(f"Error: {exc}", file=sys.stderr)
603
+ return 1
604
+ finally:
605
+ if runtime is not None and worker is not None:
606
+ await runtime.shutdown()
607
+ await worker
608
+
609
+
610
+ def main(argv: Sequence[str] | None = None) -> int:
611
+ raw_args = list(argv) if argv is not None else None
612
+ if raw_args is None:
613
+ raw_args = sys.argv[1:]
614
+
615
+ if raw_args and raw_args[0] == "doctor":
616
+ from .doctor import build_doctor_parser, run_doctor_cli
617
+
618
+ parser = build_doctor_parser()
619
+ args = parser.parse_args(raw_args[1:])
620
+ try:
621
+ return asyncio.run(run_doctor_cli(args))
622
+ except ValueError as exc:
623
+ parser.error(str(exc))
624
+ except KeyboardInterrupt:
625
+ return 130
626
+ return 0
627
+
628
+ parser = build_parser()
629
+ args = parser.parse_args(raw_args)
630
+
631
+ try:
632
+ return asyncio.run(run_cli(args))
633
+ except ValueError as exc:
634
+ parser.error(str(exc))
635
+ except KeyboardInterrupt:
636
+ return 130
637
+ return 0
638
+
639
+
640
+ if __name__ == "__main__":
641
+ raise SystemExit(main())
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ CollaborationMode = Literal["default", "plan", "execute", "pair_programming"]
6
+
7
+ DEFAULT_COLLABORATION_MODE: CollaborationMode = "default"
8
+ PLAN_COLLABORATION_MODE: CollaborationMode = "plan"
9
+
10
+ _MODE_DISPLAY_NAMES: dict[str, str] = {
11
+ "default": "Default",
12
+ "plan": "Plan",
13
+ "execute": "Execute",
14
+ "pair_programming": "Pair Programming",
15
+ }
16
+
17
+
18
+ def collaboration_mode_display_name(mode: str | None) -> str:
19
+ normalized = (mode or DEFAULT_COLLABORATION_MODE).strip().lower()
20
+ return _MODE_DISPLAY_NAMES.get(normalized, normalized.replace("_", " ").title())
21
+