python-codex 0.2.6__py3-none-any.whl → 0.3.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 (84) hide show
  1. pycodex/__init__.py +18 -14
  2. pycodex/agent.py +468 -462
  3. pycodex/bootstrap.py +417 -0
  4. pycodex/cli.py +236 -436
  5. pycodex/compat.py +19 -5
  6. pycodex/context.py +222 -212
  7. pycodex/doctor.py +52 -48
  8. pycodex/events.py +857 -0
  9. pycodex/feishu_card.py +217 -163
  10. pycodex/feishu_link.py +43 -83
  11. pycodex/model.py +329 -252
  12. pycodex/model_metadata.py +19 -7
  13. pycodex/portable.py +90 -52
  14. pycodex/portable_server.py +32 -24
  15. pycodex/prompts/models.json +235 -803
  16. pycodex/protocol.py +177 -137
  17. pycodex/runtime.py +579 -174
  18. pycodex/runtime_services.py +204 -157
  19. pycodex/tools/__init__.py +4 -1
  20. pycodex/tools/apply_patch_tool.py +69 -48
  21. pycodex/tools/base_tool.py +89 -42
  22. pycodex/tools/clock_tool.py +201 -0
  23. pycodex/tools/close_agent_tool.py +2 -2
  24. pycodex/tools/code_mode_manager.py +77 -64
  25. pycodex/tools/exec_command_tool.py +26 -11
  26. pycodex/tools/exec_tool.py +4 -4
  27. pycodex/tools/grep_files_tool.py +12 -10
  28. pycodex/tools/ipython_tool.py +10 -13
  29. pycodex/tools/list_dir_tool.py +13 -9
  30. pycodex/tools/read_file_tool.py +29 -17
  31. pycodex/tools/request_permissions_tool.py +15 -5
  32. pycodex/tools/request_user_input_tool.py +13 -104
  33. pycodex/tools/resume_agent_tool.py +2 -2
  34. pycodex/tools/send_input_tool.py +11 -8
  35. pycodex/tools/shell_command_tool.py +7 -5
  36. pycodex/tools/shell_tool.py +7 -5
  37. pycodex/tools/spawn_agent_tool.py +7 -4
  38. pycodex/tools/unified_exec_manager.py +102 -69
  39. pycodex/tools/update_plan_tool.py +8 -5
  40. pycodex/tools/view_image_tool.py +13 -13
  41. pycodex/tools/wait_agent_tool.py +27 -4
  42. pycodex/tools/wait_tool.py +5 -4
  43. pycodex/tools/web_search_tool.py +4 -2
  44. pycodex/tools/write_stdin_tool.py +12 -11
  45. pycodex/utils/__init__.py +2 -17
  46. pycodex/utils/compactor.py +50 -66
  47. pycodex/utils/debug.py +2 -2
  48. pycodex/utils/dotenv.py +6 -7
  49. pycodex/utils/event_helpers.py +190 -0
  50. pycodex/utils/get_env.py +27 -70
  51. pycodex/utils/image_utils.py +76 -0
  52. pycodex/utils/random_ids.py +1 -2
  53. pycodex/utils/session_persist.py +263 -161
  54. pycodex/utils/truncation.py +21 -45
  55. python_codex-0.3.0.dist-info/METADATA +704 -0
  56. python_codex-0.3.0.dist-info/RECORD +90 -0
  57. responses_server/__init__.py +1 -5
  58. responses_server/__main__.py +0 -1
  59. responses_server/app.py +36 -31
  60. responses_server/config.py +25 -22
  61. responses_server/messages_api.py +96 -49
  62. responses_server/payload_processors.py +25 -19
  63. responses_server/server.py +11 -11
  64. responses_server/session_store.py +14 -11
  65. responses_server/stream_router.py +196 -107
  66. responses_server/tools/custom_adapter.py +17 -16
  67. responses_server/tools/web_search.py +39 -36
  68. responses_server/trajectory_dump.py +51 -13
  69. workspace_server/__main__.py +0 -1
  70. workspace_server/app.py +470 -384
  71. workspace_server/workspace.html +859 -232
  72. workspace_server/workspaces.html +94 -95
  73. workspace_server/workspaces.py +168 -100
  74. pycodex/collaboration.py +0 -20
  75. pycodex/interactive_session.py +0 -415
  76. pycodex/prompts/collaboration_default.md +0 -11
  77. pycodex/prompts/collaboration_plan.md +0 -128
  78. pycodex/utils/toolcall_visualize.py +0 -713
  79. pycodex/utils/visualize.py +0 -553
  80. python_codex-0.2.6.dist-info/METADATA +0 -441
  81. python_codex-0.2.6.dist-info/RECORD +0 -91
  82. {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
  83. {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
  84. {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/licenses/LICENSE +0 -0
pycodex/cli.py CHANGED
@@ -1,73 +1,31 @@
1
-
2
- import atexit
3
1
  import argparse
4
2
  import asyncio
3
+ import inspect
5
4
  import os
6
5
  import shlex
6
+ import signal
7
7
  import sys
8
8
  import tempfile
9
+ import threading
9
10
  import traceback
10
- from dataclasses import replace
11
- from pathlib import Path
12
- from typing import Sequence
13
-
14
- from .agent import Agent
15
- from .collaboration import DEFAULT_COLLABORATION_MODE, CollaborationMode
16
- from .compat import Literal
17
- from .context import ContextManager
18
- from .model import DEFAULT_CODEX_CONFIG_PATH, ResponsesModelClient, ResponsesProviderConfig
19
- from .portable import bootstrap_called_home, upload_codex_home
20
- from .runtime import CliSubmissionQueue
21
- from .runtime_services import AgentRuntimeEnvironment, create_agent_runtime_environment
22
- from .utils import CliSessionView, get_debug_dir, load_codex_dotenv, uuid7_string
23
- from .interactive_session import (
24
- EXTRA_COMMANDS_LINE,
25
- format_turn_output,
26
- run_interactive_session as _run_interactive_session,
27
- prompt_request_permissions,
28
- prompt_request_user_input,
29
- )
30
- from .utils.session_persist import (
31
- SessionRolloutRecorder,
32
- resolve_codex_home,
33
- )
34
11
  import typing
12
+ from contextlib import contextmanager
35
13
 
36
- CliSessionMode = Literal["exec", "tui"]
37
- LOCAL_RESPONSES_SERVER_API_KEY_ENV = "PYCODEX_LOCAL_RESPONSES_SERVER_KEY"
38
- CLI_ORIGINATOR = "codex-tui"
39
-
40
-
41
- def launch_chat_completion_compat_server(*args, **kwargs):
42
- from responses_server import (
43
- launch_chat_completion_compat_server as launch_compat_server,
44
- )
45
-
46
- return launch_compat_server(*args, **kwargs)
47
-
48
-
49
- def configure_loguru() -> 'None':
50
- try:
51
- from loguru import logger
52
- except ImportError: # pragma: no cover - dependency may be absent in minimal envs
53
- return
54
-
55
- logger.remove()
56
- debug_dir = get_debug_dir()
57
- if debug_dir is not None:
58
- logger.add(str(debug_dir / "loguru.log"), level="DEBUG")
59
- return
14
+ from prompt_toolkit import PromptSession
15
+ from prompt_toolkit.enums import DEFAULT_BUFFER
16
+ from prompt_toolkit.filters import has_focus
17
+ from prompt_toolkit.key_binding import KeyBindings
18
+ from prompt_toolkit.patch_stdout import patch_stdout
60
19
 
61
- if os.environ.get("PYCODEX_DEBUG_STDERR", "").strip().lower() in {
62
- "1",
63
- "true",
64
- "yes",
65
- "on",
66
- }:
67
- logger.add(sys.stderr, level="DEBUG")
20
+ from .bootstrap import build_agent, build_model, build_runtime, configure_loguru
21
+ from .events import DEFAULT_MAIN_PROMPT, EventDisplay
22
+ from .model import DEFAULT_CODEX_CONFIG_PATH
23
+ from .portable import bootstrap_called_home, upload_codex_home
24
+ from .utils import get_debug_dir
25
+ from .utils.event_helpers import format_error, render_result
68
26
 
69
27
 
70
- def build_parser() -> 'argparse.ArgumentParser':
28
+ def build_parser():
71
29
  parser = argparse.ArgumentParser(
72
30
  prog="pycodex",
73
31
  description="Minimal Codex-style local CLI backed by ~/.codex/config.toml.",
@@ -79,17 +37,12 @@ def build_parser() -> 'argparse.ArgumentParser':
79
37
  "--put",
80
38
  default=None,
81
39
  metavar="PATH@SERVER",
82
- help=(
83
- "Upload a Codex home using `--put @host:port` or "
84
- "`--put /path/.codex@host:port`."
85
- ),
40
+ help="Upload a Codex home using `--put @host:port` or `--put /path/.codex@host:port`.",
86
41
  )
87
42
  parser.add_argument(
88
43
  "--call",
89
44
  default=None,
90
- help=(
91
- "Download and use a stored Codex home via <secret>-<call_id>@<host:port>."
92
- ),
45
+ help="Download and use a stored Codex home via <secret>-<call_id>@<host:port>.",
93
46
  )
94
47
  parser.add_argument(
95
48
  "--config",
@@ -97,35 +50,24 @@ def build_parser() -> 'argparse.ArgumentParser':
97
50
  help="Path to Codex config.toml.",
98
51
  )
99
52
  parser.add_argument(
100
- "--profile",
101
- default=None,
102
- help="Optional profile name from config.toml.",
53
+ "--profile", default=None, help="Optional profile name from config.toml."
103
54
  )
104
55
  parser.add_argument(
105
56
  "--vllm-endpoint",
106
57
  default=None,
107
- help=(
108
- "Optional base URL for a chat-completions-backed vLLM server. "
109
- "When set, pycodex starts a local responses compat server for this "
110
- "session and appends /v1 if the path is empty."
111
- ),
58
+ help="Start a local responses compat server for a chat-completions-backed vLLM endpoint.",
112
59
  )
113
60
  parser.add_argument(
114
61
  "--use-chat-completion",
115
62
  default=False,
116
63
  action="store_true",
117
- help=(
118
- "When set, pycodex starts a local responses compat server for this session."
119
- ),
64
+ help="Start a local responses compat server for this session.",
120
65
  )
121
66
  parser.add_argument(
122
67
  "--use-messages",
123
68
  default=False,
124
69
  action="store_true",
125
- help=(
126
- "When set, pycodex starts a local responses compat server and routes "
127
- "to a downstream /v1/messages backend for this session."
128
- ),
70
+ help="Route the local responses compat server to a downstream /v1/messages backend.",
129
71
  )
130
72
  parser.add_argument(
131
73
  "--system-prompt",
@@ -139,322 +81,39 @@ def build_parser() -> 'argparse.ArgumentParser':
139
81
  help="HTTP timeout for one model call.",
140
82
  )
141
83
  parser.add_argument(
142
- "--json",
143
- action="store_true",
144
- help="Print the full TurnResult as JSON.",
84
+ "--json", action="store_true", help="Print the full TurnResult as JSON."
145
85
  )
146
86
  return parser
147
87
 
148
88
 
149
- def should_run_interactive(prompt_parts: 'Sequence[str]', stdin_is_tty: 'bool') -> 'bool':
89
+ def should_run_interactive(prompt_parts, stdin_is_tty):
150
90
  return not prompt_parts and stdin_is_tty
151
91
 
152
92
 
153
- def resolve_prompt_text(prompt_parts: 'Sequence[str]') -> 'str':
93
+ def resolve_prompt_text(prompt_parts):
154
94
  if prompt_parts:
155
95
  return " ".join(prompt_parts).strip()
156
-
157
96
  if not sys.stdin.isatty():
158
97
  prompt_text = sys.stdin.read().strip()
159
98
  if prompt_text:
160
99
  return prompt_text
161
-
162
100
  raise ValueError("prompt is required either as argv text or stdin")
163
101
 
164
102
 
165
- def get_tools(
166
- runtime_environment: 'typing.Union[AgentRuntimeEnvironment, None]' = None,
167
- exec_mode: 'bool' = False,
168
- cwd: 'typing.Union[str, Path, None]' = None,
169
- ):
170
- from .tools import (
171
- ApplyPatchTool,
172
- CloseAgentTool,
173
- CodeModeManager,
174
- ExecTool,
175
- ExecCommandTool,
176
- GrepFilesTool,
177
- ListDirTool,
178
- ReadFileTool,
179
- RequestPermissionsTool,
180
- RequestUserInputTool,
181
- ResumeAgentTool,
182
- Registry,
183
- SendInputTool,
184
- ShellCommandTool,
185
- ShellTool,
186
- SpawnAgentTool,
187
- UnifiedExecManager,
188
- UpdatePlanTool,
189
- ViewImageTool,
190
- WaitAgentTool,
191
- WaitTool,
192
- WebSearchTool,
193
- WriteStdinTool,
194
- )
195
-
196
- runtime_environment = runtime_environment or create_agent_runtime_environment()
197
- registry = Registry()
198
- code_mode_manager = CodeModeManager(registry, cwd=cwd)
199
- unified_exec_manager = UnifiedExecManager(cwd=cwd)
200
- exec_tool = ExecTool(code_mode_manager)
201
- wait_tool = WaitTool(code_mode_manager)
202
- web_search_tool = WebSearchTool()
203
- update_plan_tool = UpdatePlanTool(runtime_environment.plan_store)
204
- request_user_input_tool = RequestUserInputTool(
205
- runtime_environment.request_user_input_manager
206
- )
207
- request_permissions_tool = RequestPermissionsTool(
208
- runtime_environment.request_permissions_manager
209
- )
210
- spawn_agent_tool = SpawnAgentTool(runtime_environment.subagent_manager)
211
- send_input_tool = SendInputTool(runtime_environment.subagent_manager)
212
- resume_agent_tool = ResumeAgentTool(runtime_environment.subagent_manager)
213
- wait_agent_tool = WaitAgentTool(runtime_environment.subagent_manager)
214
- close_agent_tool = CloseAgentTool(runtime_environment.subagent_manager)
215
- apply_patch_tool = ApplyPatchTool(cwd=cwd)
216
- shell_tool = ShellTool(cwd=cwd)
217
- shell_command_tool = ShellCommandTool(cwd=cwd)
218
- exec_command_tool = ExecCommandTool(unified_exec_manager)
219
- write_stdin_tool = WriteStdinTool(unified_exec_manager)
220
- grep_files_tool = GrepFilesTool(cwd=cwd)
221
- read_file_tool = ReadFileTool()
222
- list_dir_tool = ListDirTool()
223
- view_image_tool = ViewImageTool(cwd=cwd)
224
- if exec_mode:
225
- registry.register(exec_command_tool)
226
- registry.register(write_stdin_tool)
227
- registry.register(update_plan_tool)
228
- registry.register(request_user_input_tool)
229
- registry.register(apply_patch_tool)
230
- registry.register(web_search_tool)
231
- registry.register(view_image_tool)
232
- registry.register(spawn_agent_tool)
233
- registry.register(send_input_tool)
234
- registry.register(resume_agent_tool)
235
- registry.register(wait_agent_tool)
236
- registry.register(close_agent_tool)
237
- return registry
238
-
239
- registry.register(shell_tool)
240
- registry.register(shell_command_tool)
241
- registry.register(exec_command_tool)
242
- registry.register(write_stdin_tool)
243
- registry.register(exec_tool)
244
- registry.register(wait_tool)
245
- registry.register(web_search_tool)
246
- registry.register(update_plan_tool)
247
- registry.register(request_user_input_tool)
248
- registry.register(request_permissions_tool)
249
- registry.register(spawn_agent_tool)
250
- registry.register(send_input_tool)
251
- registry.register(resume_agent_tool)
252
- registry.register(wait_agent_tool)
253
- registry.register(close_agent_tool)
254
- registry.register(apply_patch_tool)
255
- registry.register(grep_files_tool)
256
- registry.register(read_file_tool)
257
- registry.register(list_dir_tool)
258
- registry.register(view_image_tool)
259
- return registry
260
-
261
-
262
- def get_subagent_tools(
263
- runtime_environment: 'typing.Union[AgentRuntimeEnvironment, None]' = None,
264
- cwd: 'typing.Union[str, Path, None]' = None,
265
- ):
266
- from .tools import (
267
- ApplyPatchTool,
268
- ExecCommandTool,
269
- Registry,
270
- UnifiedExecManager,
271
- UpdatePlanTool,
272
- ViewImageTool,
273
- WebSearchTool,
274
- WriteStdinTool,
275
- )
276
-
277
- runtime_environment = runtime_environment or create_agent_runtime_environment()
278
- registry = Registry()
279
- unified_exec_manager = UnifiedExecManager(cwd=cwd)
280
- registry.register(ExecCommandTool(unified_exec_manager))
281
- registry.register(WriteStdinTool(unified_exec_manager))
282
- registry.register(UpdatePlanTool(runtime_environment.plan_store))
283
- registry.register(ApplyPatchTool(cwd=cwd))
284
- registry.register(WebSearchTool())
285
- registry.register(ViewImageTool(cwd=cwd))
286
- return registry
287
-
288
-
289
- def build_agent(
290
- client,
291
- config_path: 'typing.Union[str, Path]' = DEFAULT_CODEX_CONFIG_PATH,
292
- profile: 'typing.Union[str, None]' = None,
293
- system_prompt: 'typing.Union[str, None]' = None,
294
- session_mode: 'CliSessionMode' = "exec",
295
- collaboration_mode: 'CollaborationMode' = DEFAULT_COLLABORATION_MODE,
296
- extra_contextual_user_messages: 'typing.Iterable[str]' = (),
297
- cwd: 'typing.Union[str, Path, None]' = None,
298
- ) -> 'Agent':
299
- config_path = str(config_path)
300
- resolved_cwd = Path(cwd or Path.cwd()).resolve()
301
- context_manager = ContextManager.from_codex_config(
302
- config_path,
303
- profile,
304
- base_instructions_override=system_prompt,
305
- collaboration_mode=collaboration_mode,
306
- include_collaboration_instructions=session_mode == "tui",
307
- extra_contextual_user_messages=extra_contextual_user_messages,
308
- cwd=resolved_cwd,
309
- )
310
- session_id = getattr(client, "_session_id", None) or uuid7_string()
311
- if hasattr(client, "_session_id"):
312
- client._session_id = session_id
313
- subagent_context_manager = ContextManager.from_codex_config(
314
- config_path,
315
- profile,
316
- base_instructions_override=system_prompt,
317
- include_collaboration_instructions=False,
318
- extra_contextual_user_messages=extra_contextual_user_messages,
319
- cwd=resolved_cwd,
320
- )
321
- runtime_environment = create_agent_runtime_environment()
322
- runtime_environment.request_user_input_manager.set_handler(None)
323
- runtime_environment.request_permissions_manager.set_handler(None)
324
- rollout_recorder = SessionRolloutRecorder.create(
325
- resolve_codex_home(config_path),
326
- session_id,
327
- context_manager.cwd,
328
- getattr(client, "_originator", CLI_ORIGINATOR),
329
- getattr(getattr(client, "_config", None), "provider_name", None),
330
- context_manager.resolve_base_instructions(),
331
- )
332
-
333
- def make_subagent_queue_builder(base_client):
334
- def build_subagent_queue(
335
- model_override: 'typing.Union[str, None]',
336
- reasoning_effort_override: 'typing.Union[str, None]',
337
- initial_history=(),
338
- session_id: 'typing.Union[str, None]' = None,
339
- ) -> 'CliSubmissionQueue':
340
- nested_client = base_client.with_overrides(
341
- model_override,
342
- reasoning_effort_override,
343
- session_id=session_id,
344
- openai_subagent="collab_spawn",
345
- )
346
- subagent_agent_runtime_environment = create_agent_runtime_environment()
347
- subagent_agent_runtime_environment.request_user_input_manager.set_handler(None)
348
- subagent_agent_runtime_environment.request_permissions_manager.set_handler(None)
349
- subagent_agent_runtime_environment.subagent_manager.set_queue_builder(
350
- make_subagent_queue_builder(nested_client)
351
- )
352
- sub_agent = Agent(
353
- nested_client,
354
- get_subagent_tools(subagent_agent_runtime_environment, cwd=resolved_cwd),
355
- subagent_context_manager,
356
- initial_history=tuple(initial_history),
357
- runtime_environment=subagent_agent_runtime_environment,
358
- )
359
- return CliSubmissionQueue(sub_agent)
360
-
361
- return build_subagent_queue
362
-
363
- runtime_environment.subagent_manager.set_queue_builder(
364
- make_subagent_queue_builder(client)
365
- )
366
- return Agent(
367
- client,
368
- get_tools(runtime_environment, exec_mode=True, cwd=resolved_cwd),
369
- context_manager,
370
- rollout_recorder=rollout_recorder,
371
- runtime_environment=runtime_environment,
372
- )
373
-
374
-
375
- def build_model(
376
- config_path: 'typing.Union[str, Path]' = DEFAULT_CODEX_CONFIG_PATH,
377
- profile: 'typing.Union[str, None]' = None,
378
- timeout_seconds: 'float' = 120.0,
379
- managed_responses_base_url: 'typing.Union[str, None]' = None,
380
- vllm_endpoint: 'typing.Union[str, None]' = None,
381
- use_chat_completion: 'typing.Union[bool, None]' = None,
382
- use_messages: 'bool' = False,
383
- ):
384
- load_codex_dotenv(config_path)
385
- provider_config = ResponsesProviderConfig.from_codex_config(
386
- config_path,
387
- profile,
388
- )
389
- if use_chat_completion is None:
390
- use_chat_completion = bool(provider_config.use_chat_completion)
391
- if use_chat_completion and use_messages:
392
- raise ValueError("--use-chat-completion and --use-messages cannot be combined")
393
- if vllm_endpoint and use_messages:
394
- raise ValueError("--vllm-endpoint and --use-messages cannot be combined")
395
- url, key_env = provider_config.base_url, provider_config.api_key_env
396
- if managed_responses_base_url is not None:
397
- url, key_env = (
398
- managed_responses_base_url,
399
- LOCAL_RESPONSES_SERVER_API_KEY_ENV,
400
- )
401
- os.environ.setdefault(LOCAL_RESPONSES_SERVER_API_KEY_ENV, "dummy")
402
- elif vllm_endpoint or use_chat_completion or use_messages:
403
- if vllm_endpoint:
404
- managed_server = launch_chat_completion_compat_server(
405
- vllm_endpoint,
406
- model_provider="vllm",
407
- )
408
- else:
409
- managed_server = launch_chat_completion_compat_server(
410
- provider_config.base_url,
411
- provider_config.api_key_env,
412
- model_provider=provider_config.provider_name,
413
- outcomming_api=(
414
- "messages" if use_messages else "chat_completions"
415
- ),
416
- )
417
- atexit.register(managed_server.stop)
418
- url, key_env = (
419
- managed_server.base_url,
420
- LOCAL_RESPONSES_SERVER_API_KEY_ENV,
421
- )
422
- os.environ.setdefault(LOCAL_RESPONSES_SERVER_API_KEY_ENV, "dummy")
423
-
424
- provider_config = replace(
425
- provider_config,
426
- base_url=url,
427
- api_key_env=key_env,
428
- )
429
- return ResponsesModelClient(
430
- provider_config,
431
- timeout_seconds,
432
- originator=CLI_ORIGINATOR,
433
- )
434
-
435
-
436
- def build_cli_queue(agent: 'Agent') -> 'CliSubmissionQueue':
437
- return CliSubmissionQueue(agent)
438
-
439
-
440
- async def run_interactive_session(
441
- queue: 'CliSubmissionQueue',
442
- json_mode: 'bool',
443
- config_path: 'typing.Union[str, None]' = None,
444
- ) -> 'int':
445
- return await _run_interactive_session(
446
- queue,
447
- json_mode,
448
- config_path,
449
- view_factory=CliSessionView,
103
+ async def run_cli(args):
104
+ runtime = None
105
+ debug_dir = get_debug_dir()
106
+ phase_handle = (
107
+ None
108
+ if debug_dir is None
109
+ else (debug_dir / "phase.log").open("a", encoding="utf-8")
450
110
  )
451
111
 
112
+ def phase(message):
113
+ if phase_handle is not None:
114
+ phase_handle.write(message + "\n")
115
+ phase_handle.flush()
452
116
 
453
- async def run_cli(args: 'argparse.Namespace') -> 'int':
454
- queued_agent = None
455
- worker = None
456
- debug_dir = get_debug_dir()
457
- phase_handle = None if debug_dir is None else (debug_dir / "phase.log").open("a", encoding="utf-8")
458
117
  try:
459
118
  if args.put is not None and args.call:
460
119
  raise ValueError("--put and --call cannot be combined")
@@ -463,7 +122,8 @@ async def run_cli(args: 'argparse.Namespace') -> 'int':
463
122
  configure_loguru()
464
123
  config_path = args.config
465
124
  if args.put is not None:
466
- def emit_put_log(message: 'str') -> 'None':
125
+
126
+ def emit_put_log(message):
467
127
  print(message, flush=True)
468
128
 
469
129
  call_spec = upload_codex_home(args.put, event_handler=emit_put_log)
@@ -475,17 +135,11 @@ async def run_cli(args: 'argparse.Namespace') -> 'int':
475
135
  print(f"pycodex --call {shlex.quote(call_spec)}", flush=True)
476
136
  return 0
477
137
  if args.call:
478
- if phase_handle is not None:
479
- phase_handle.write("bootstrap_called_home:start\n")
480
- phase_handle.flush()
138
+ phase("bootstrap_called_home:start")
481
139
  config_path = bootstrap_called_home(args.call)
482
- if phase_handle is not None:
483
- phase_handle.write("bootstrap_called_home:done\n")
484
- phase_handle.flush()
140
+ phase("bootstrap_called_home:done")
485
141
  os.environ["CODEX_HOME"] = str(config_path.parent)
486
- if phase_handle is not None:
487
- phase_handle.write("build_model:start\n")
488
- phase_handle.flush()
142
+ phase("build_model:start")
489
143
  model = build_model(
490
144
  config_path=str(config_path),
491
145
  profile=args.profile,
@@ -494,79 +148,227 @@ async def run_cli(args: 'argparse.Namespace') -> 'int':
494
148
  use_chat_completion=args.use_chat_completion or None,
495
149
  use_messages=args.use_messages,
496
150
  )
497
- if phase_handle is not None:
498
- phase_handle.write("build_model:done\n")
499
- phase_handle.write("build_agent:start\n")
500
- phase_handle.flush()
151
+ phase("build_model:done")
152
+ phase("build_agent:start")
501
153
  agent = build_agent(
502
154
  model,
503
155
  config_path=str(config_path),
504
156
  profile=args.profile,
505
157
  system_prompt=args.system_prompt,
506
- session_mode="tui",
507
158
  )
508
- if phase_handle is not None:
509
- phase_handle.write("build_agent:done\n")
510
- phase_handle.write("build_cli_queue:start\n")
511
- phase_handle.flush()
512
- queued_agent = build_cli_queue(agent)
513
- if phase_handle is not None:
514
- phase_handle.write("build_cli_queue:done\n")
515
- phase_handle.flush()
159
+ phase("build_agent:done")
160
+ runtime = build_runtime(agent)
516
161
  if should_run_interactive(args.prompt, sys.stdin.isatty()):
517
- return await run_interactive_session(
518
- queued_agent,
519
- args.json,
520
- str(config_path),
521
- )
522
- else:
523
- prompt_text = resolve_prompt_text(args.prompt)
524
- worker = asyncio.create_task(queued_agent.run_forever())
525
- if phase_handle is not None:
526
- phase_handle.write("submit_user_turn:start\n")
527
- phase_handle.flush()
528
- result = await queued_agent.submit_user_turn(prompt_text)
529
- if phase_handle is not None:
530
- phase_handle.write("submit_user_turn:done\n")
531
- phase_handle.flush()
532
- print(format_turn_output(result, args.json))
533
- return 0
162
+ return await run_interactive_session(runtime, args.json, str(config_path))
163
+ prompt_text = resolve_prompt_text(args.prompt)
164
+ await runtime.start(str(config_path))
165
+ phase("submit_input:start")
166
+ receipt = await runtime.submit_input(prompt_text, "cli")
167
+ result = await receipt.future
168
+ phase("submit_input:done")
169
+ render_result(receipt.kind, result, args.json, print)
170
+ return 0
534
171
  except Exception as exc:
535
- if phase_handle is not None:
536
- phase_handle.write("fatal_exception\n")
537
- phase_handle.flush()
172
+ phase("fatal_exception")
538
173
  if debug_dir is not None:
539
174
  (debug_dir / "fatal_error.txt").write_text(
540
175
  traceback.format_exc(), encoding="utf-8"
541
176
  )
542
- print(f"Error: {exc}", file=sys.stderr)
177
+ print(format_error(exc, indent=False), file=sys.stderr)
543
178
  return 1
544
179
  finally:
545
180
  if phase_handle is not None:
546
181
  phase_handle.close()
547
- if queued_agent is not None and worker is not None:
548
- await queued_agent.shutdown()
549
- await worker
182
+ if runtime is not None:
183
+ await runtime.close()
184
+
185
+
186
+ @contextmanager
187
+ def _cli_sigint_handler(runtime):
188
+ if threading.current_thread() is not threading.main_thread():
189
+ yield lambda: None
190
+ return
191
+
192
+ def handle_sigint(signum, frame):
193
+ if not runtime.accepts_input:
194
+ # A second interrupt must not re-enter asyncio's shutdown waits.
195
+ os._exit(130)
196
+ signal.default_int_handler(signum, frame)
197
+
198
+ def install_handler():
199
+ signal.signal(signal.SIGINT, handle_sigint)
200
+
201
+ previous_handler = signal.getsignal(signal.SIGINT)
202
+ install_handler()
203
+ try:
204
+ yield install_handler
205
+ finally:
206
+ signal.signal(signal.SIGINT, previous_handler)
207
+
208
+
209
+ async def run_interactive_session(runtime, json_mode, config_path=None, view=None):
210
+ if view is None:
211
+ view = CliSessionView()
212
+ await runtime.start(config_path)
213
+ frontend_id = runtime.attach(view.handle_event)
214
+
215
+ def show_result(future):
216
+ if not future.cancelled() and future.exception() is None:
217
+ render_result("turn", future.result(), True, view.write_line)
218
+
219
+ view.display.start(runtime.commands())
220
+ with _cli_sigint_handler(runtime) as install_sigint_handler:
221
+ try:
222
+ while not view.display.closed:
223
+ try:
224
+ raw_line = await view.poll_prompt()
225
+ except EOFError:
226
+ break
227
+ if raw_line is None:
228
+ await asyncio.sleep(0.05)
229
+ continue
230
+ install_sigint_handler()
231
+ try:
232
+ receipt = await runtime.submit_input(raw_line, sender="cli")
233
+ except Exception as exc:
234
+ view.display.show_error(str(exc))
235
+ continue
236
+ if json_mode and receipt.kind == "turn":
237
+ receipt.future.add_done_callback(show_result)
238
+ finally:
239
+ # Older prompt_toolkit versions reset SIGINT when the prompt exits.
240
+ install_sigint_handler()
241
+ try:
242
+ await runtime.close()
243
+ finally:
244
+ runtime.detach(frontend_id)
245
+ view.close()
246
+ return 0
247
+
248
+
249
+ class Prompter:
250
+ def __init__(self, prompt: str = DEFAULT_MAIN_PROMPT, lock=None):
251
+ self.lock = lock or threading.Lock()
252
+ self._prompt_session = PromptSession(**prompt_session_kwargs())
253
+ self.prompt = prompt
254
+ self._status = None
255
+ self._status_frame_index = 0
256
+ self._prompt_task = None
257
+
258
+ def set_prompt(self, prompt):
259
+ self.prompt = prompt
260
+
261
+ def set_status(self, text):
262
+ self._status = text
263
+
264
+ async def poll_input(self) -> "typing.Union[str, None]":
265
+ if self._prompt_task is None:
266
+ self._prompt_task = asyncio.create_task(self._block_prompt())
267
+ done, _pending = await asyncio.wait(
268
+ {self._prompt_task},
269
+ timeout=0.05,
270
+ return_when=asyncio.FIRST_COMPLETED,
271
+ )
272
+ if not done:
273
+ return None
274
+ prompt_task, self._prompt_task = self._prompt_task, None
275
+ try:
276
+ return prompt_task.result()
277
+ except asyncio.CancelledError:
278
+ return None
279
+
280
+ async def _block_prompt(self):
281
+ with patch_stdout(raw=True):
282
+ return await self._prompt_session.prompt_async(
283
+ lambda: self.prompt,
284
+ refresh_interval=0.12,
285
+ bottom_toolbar=self._get_status,
286
+ set_exception_handler=False,
287
+ )
288
+
289
+ def _get_status(self):
290
+ self._status_frame_index += 1
291
+ return EventDisplay.status_frame(self._status, self._status_frame_index)
292
+
293
+ def close(self) -> "None":
294
+ if self._prompt_task is not None and not self._prompt_task.done():
295
+ self._prompt_task.cancel()
296
+ self._prompt_task = None
297
+
298
+
299
+ def prompt_session_kwargs() -> "typing.Dict[str, object]":
300
+ key_bindings = KeyBindings()
301
+
302
+ @key_bindings.add("c-c", filter=has_focus(DEFAULT_BUFFER))
303
+ @key_bindings.add("<sigint>")
304
+ def exit_prompt(event):
305
+ event.app.exit(exception=EOFError, style="class:aborting")
306
+
307
+ kwargs = {
308
+ "erase_when_done": True,
309
+ "enable_system_prompt": True,
310
+ "key_bindings": key_bindings,
311
+ }
312
+ try:
313
+ parameters = inspect.signature(PromptSession.__init__).parameters
314
+ except (TypeError, ValueError):
315
+ return kwargs
316
+ if "show_frame" in parameters:
317
+ kwargs["show_frame"] = True
318
+ return kwargs
319
+
320
+
321
+ class CliSessionView:
322
+ """Execute terminal I/O; events own presentation decisions and state."""
323
+
324
+ def __init__(self, context_window_tokens=None):
325
+ self._line_output = print
326
+ self._terminal_lock = threading.RLock()
327
+ self.prompter = Prompter(lock=self._terminal_lock)
328
+ color_enabled = sys.stdout.isatty() and os.environ.get(
329
+ "PYCODEX_NO_COLOR",
330
+ "",
331
+ ).strip().lower() not in {"1", "true", "yes", "on"}
332
+ self.display = EventDisplay(
333
+ self.write_line,
334
+ self.prompter.set_status,
335
+ self.prompter.set_prompt,
336
+ color_enabled,
337
+ context_window_tokens,
338
+ )
550
339
 
551
- def ipython_agent(config_path: 'str' = DEFAULT_CODEX_CONFIG_PATH):
340
+ def handle_event(self, event):
341
+ with self._terminal_lock:
342
+ event.render(self.display)
343
+
344
+ def write_line(self, text):
345
+ with self._terminal_lock:
346
+ self._line_output(text)
347
+
348
+ async def poll_prompt(self, prompt=None):
349
+ if prompt:
350
+ self.prompter.set_prompt(prompt)
351
+ return await self.prompter.poll_input()
352
+
353
+ def close(self):
354
+ self.prompter.close()
355
+
356
+
357
+ def ipython_agent(config_path=DEFAULT_CODEX_CONFIG_PATH):
552
358
  from loguru import logger
359
+
360
+ from .tools.ipython_tool import attach_ipython_tool
361
+
553
362
  logger.remove()
554
363
  logger.add(sys.stderr, level="INFO")
555
-
556
364
  model = build_model(config_path)
557
365
  agent = build_agent(client=model, config_path=config_path)
558
-
559
- from pycodex.tools.ipython_tool import attach_ipython_tool
560
-
561
366
  attach_ipython_tool(agent)
562
-
563
367
  return agent
564
368
 
565
- def main(argv: 'typing.Union[Sequence[str], None]' = None) -> 'int':
566
- raw_args = list(argv) if argv is not None else None
567
- if raw_args is None:
568
- raw_args = sys.argv[1:]
569
369
 
370
+ def main(argv=None):
371
+ raw_args = list(argv) if argv is not None else sys.argv[1:]
570
372
  if raw_args and raw_args[0] == "doctor":
571
373
  from .doctor import build_doctor_parser, run_doctor_cli
572
374
 
@@ -579,10 +381,8 @@ def main(argv: 'typing.Union[Sequence[str], None]' = None) -> 'int':
579
381
  except KeyboardInterrupt:
580
382
  return 130
581
383
  return 0
582
-
583
384
  parser = build_parser()
584
385
  args = parser.parse_args(raw_args)
585
-
586
386
  try:
587
387
  return asyncio.run(run_cli(args))
588
388
  except ValueError as exc: