python-codex 0.4.7__tar.gz → 0.4.8__tar.gz

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 (91) hide show
  1. {python_codex-0.4.7 → python_codex-0.4.8}/PKG-INFO +13 -2
  2. {python_codex-0.4.7 → python_codex-0.4.8}/README.md +12 -1
  3. {python_codex-0.4.7 → python_codex-0.4.8}/README_ZH.md +6 -1
  4. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/agent.py +8 -0
  5. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/bootstrap.py +23 -26
  6. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/context.py +10 -0
  7. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/runtime.py +11 -0
  8. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/apply_patch_tool.py +24 -26
  9. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/base_tool.py +5 -0
  10. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/code_mode_manager.py +4 -9
  11. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/exec_command_tool.py +1 -2
  12. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/grep_files_tool.py +2 -16
  13. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/shell_command_tool.py +1 -17
  14. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/shell_tool.py +1 -17
  15. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/unified_exec_manager.py +2 -14
  16. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/view_image_tool.py +1 -12
  17. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/event_helpers.py +2 -0
  18. {python_codex-0.4.7 → python_codex-0.4.8}/pyproject.toml +2 -2
  19. {python_codex-0.4.7 → python_codex-0.4.8}/.gitignore +0 -0
  20. {python_codex-0.4.7 → python_codex-0.4.8}/LICENSE +0 -0
  21. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/__init__.py +0 -0
  22. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/__main__.py +0 -0
  23. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/cli.py +0 -0
  24. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/events.py +0 -0
  25. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/model.py +0 -0
  26. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/portable/__init__.py +0 -0
  27. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/portable/client.py +0 -0
  28. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/portable/server.py +0 -0
  29. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/prompts/default_base_instructions.md +0 -0
  30. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/prompts/models.json +0 -0
  31. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/prompts/permissions/approval_policy/never.md +0 -0
  32. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/prompts/permissions/approval_policy/on_failure.md +0 -0
  33. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/prompts/permissions/approval_policy/on_request.md +0 -0
  34. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/prompts/permissions/approval_policy/on_request_rule_request_permission.md +0 -0
  35. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/prompts/permissions/approval_policy/unless_trusted.md +0 -0
  36. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/prompts/permissions/sandbox_mode/danger_full_access.md +0 -0
  37. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/prompts/permissions/sandbox_mode/read_only.md +0 -0
  38. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/prompts/permissions/sandbox_mode/workspace_write.md +0 -0
  39. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/protocol.py +0 -0
  40. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/__init__.py +0 -0
  41. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/agent_tool_schemas.py +0 -0
  42. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/clock_tool.py +0 -0
  43. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/close_agent_tool.py +0 -0
  44. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/exec_runtime.js +0 -0
  45. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/exec_tool.py +0 -0
  46. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/list_dir_tool.py +0 -0
  47. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/read_file_tool.py +0 -0
  48. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/request_permissions_tool.py +0 -0
  49. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/request_user_input_tool.py +0 -0
  50. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/resume_agent_tool.py +0 -0
  51. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/runtime_services.py +0 -0
  52. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/send_input_tool.py +0 -0
  53. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/spawn_agent_tool.py +0 -0
  54. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/update_plan_tool.py +0 -0
  55. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/wait_agent_tool.py +0 -0
  56. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/wait_tool.py +0 -0
  57. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/web_search_tool.py +0 -0
  58. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/tools/write_stdin_tool.py +0 -0
  59. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/__init__.py +0 -0
  60. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/_aes_gcm/LICENSE +0 -0
  61. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/_aes_gcm/README.md +0 -0
  62. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/_aes_gcm/__init__.py +0 -0
  63. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/_aes_gcm/aes.py +0 -0
  64. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/_aes_gcm/gcm.py +0 -0
  65. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/_aes_gcm/source-sha256.json +0 -0
  66. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/async_bridge.py +0 -0
  67. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/compactor.py +0 -0
  68. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/compat.py +0 -0
  69. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/crypto.py +0 -0
  70. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/debug.py +0 -0
  71. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/doctor.py +0 -0
  72. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/dotenv.py +0 -0
  73. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/get_env.py +0 -0
  74. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/image_utils.py +0 -0
  75. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/model_metadata.py +0 -0
  76. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/random_ids.py +0 -0
  77. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/session_persist.py +0 -0
  78. {python_codex-0.4.7 → python_codex-0.4.8}/pycodex/utils/truncation.py +0 -0
  79. {python_codex-0.4.7 → python_codex-0.4.8}/responses_server/__init__.py +0 -0
  80. {python_codex-0.4.7 → python_codex-0.4.8}/responses_server/__main__.py +0 -0
  81. {python_codex-0.4.7 → python_codex-0.4.8}/responses_server/app.py +0 -0
  82. {python_codex-0.4.7 → python_codex-0.4.8}/responses_server/config.py +0 -0
  83. {python_codex-0.4.7 → python_codex-0.4.8}/responses_server/messages_api.py +0 -0
  84. {python_codex-0.4.7 → python_codex-0.4.8}/responses_server/payload_processors.py +0 -0
  85. {python_codex-0.4.7 → python_codex-0.4.8}/responses_server/server.py +0 -0
  86. {python_codex-0.4.7 → python_codex-0.4.8}/responses_server/session_store.py +0 -0
  87. {python_codex-0.4.7 → python_codex-0.4.8}/responses_server/stream_router.py +0 -0
  88. {python_codex-0.4.7 → python_codex-0.4.8}/responses_server/tools/__init__.py +0 -0
  89. {python_codex-0.4.7 → python_codex-0.4.8}/responses_server/tools/custom_adapter.py +0 -0
  90. {python_codex-0.4.7 → python_codex-0.4.8}/responses_server/tools/web_search.py +0 -0
  91. {python_codex-0.4.7 → python_codex-0.4.8}/responses_server/trajectory_dump.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-codex
3
- Version: 0.4.7
3
+ Version: 0.4.8
4
4
  Summary: A minimal Python extraction of Codex's main agent loop
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.6.2
@@ -378,10 +378,13 @@ Current behavior:
378
378
  tool execution and model follow-up after tool results
379
379
  - startup shows a welcome, model, effort, endpoint and available commands.
380
380
  - assistant text is printed from streaming deltas directly
381
- - interactive mode supports `/history`, `/title`, `/model`, `/effort`, `/resume`,
381
+ - interactive mode supports `/history`, `/title`, `/cd`, `/model`, `/effort`, `/resume`,
382
382
  `/compact`, and `/fork`
383
383
  - `/model <name>` switches the model used by later turns in the current
384
384
  interactive session; `/model` shows the current model and available choices
385
+ - `/cd <directory>` changes the current session's working directory while idle;
386
+ `/cd` shows it. Relative paths and `~` are supported; paths containing spaces
387
+ can be entered directly without quotes. Later tools and context use the new directory.
385
388
  - `/effort low` switches effort while idle; `/effort` shows the current value,
386
389
  and `/effort default` restores the model default. Frontend labels show
387
390
  `model:effort`.
@@ -454,6 +457,11 @@ Current behavior:
454
457
  original state paths and are saved in the new format. See the
455
458
  [workspace configuration example](packages/pycodex-ws/README.md).
456
459
  Add/delete actions and later session-state saves refresh the JSON file.
460
+ Each session starts in `work_dir` and can switch independently with `/cd`.
461
+ A small folder indicator appears inside the composer only when the session
462
+ directory differs from `work_dir`; hover for the full path. Directories persist
463
+ across workspace restarts, including for empty sessions. Relative chat/Canvas file links use the selected
464
+ session's directory; Canvas and workspace state files stay at their configured paths.
457
465
  Board HTML can reference local
458
466
  images beside the board (including nested paths) with relative URLs; only
459
467
  `image/*` files contained by the board directory are served.
@@ -813,6 +821,9 @@ event JSON format.
813
821
  uses `agent.tool_registry.runtime_environment`, not a forwarding Agent property.
814
822
  The global `get_agent_runtime_environment()` and
815
823
  separate `Agent(..., runtime_environment=...)` entry points are removed.
824
+ - Configure cwd once with `ContextConfig(cwd=...)` or `build_agent(cwd=...)`.
825
+ Tools use the required `ToolContext.cwd` for each call; tool constructors
826
+ and `get_tools` do not take a separate default directory.
816
827
  - Each sub-agent gets its own context manager and runtime services. The sub-agent
817
828
  service retains terminal event status and queues one notification per terminal
818
829
  event. The parent consumes these notifications before its next normal model
@@ -358,10 +358,13 @@ Current behavior:
358
358
  tool execution and model follow-up after tool results
359
359
  - startup shows a welcome, model, effort, endpoint and available commands.
360
360
  - assistant text is printed from streaming deltas directly
361
- - interactive mode supports `/history`, `/title`, `/model`, `/effort`, `/resume`,
361
+ - interactive mode supports `/history`, `/title`, `/cd`, `/model`, `/effort`, `/resume`,
362
362
  `/compact`, and `/fork`
363
363
  - `/model <name>` switches the model used by later turns in the current
364
364
  interactive session; `/model` shows the current model and available choices
365
+ - `/cd <directory>` changes the current session's working directory while idle;
366
+ `/cd` shows it. Relative paths and `~` are supported; paths containing spaces
367
+ can be entered directly without quotes. Later tools and context use the new directory.
365
368
  - `/effort low` switches effort while idle; `/effort` shows the current value,
366
369
  and `/effort default` restores the model default. Frontend labels show
367
370
  `model:effort`.
@@ -434,6 +437,11 @@ Current behavior:
434
437
  original state paths and are saved in the new format. See the
435
438
  [workspace configuration example](packages/pycodex-ws/README.md).
436
439
  Add/delete actions and later session-state saves refresh the JSON file.
440
+ Each session starts in `work_dir` and can switch independently with `/cd`.
441
+ A small folder indicator appears inside the composer only when the session
442
+ directory differs from `work_dir`; hover for the full path. Directories persist
443
+ across workspace restarts, including for empty sessions. Relative chat/Canvas file links use the selected
444
+ session's directory; Canvas and workspace state files stay at their configured paths.
437
445
  Board HTML can reference local
438
446
  images beside the board (including nested paths) with relative URLs; only
439
447
  `image/*` files contained by the board directory are served.
@@ -793,6 +801,9 @@ event JSON format.
793
801
  uses `agent.tool_registry.runtime_environment`, not a forwarding Agent property.
794
802
  The global `get_agent_runtime_environment()` and
795
803
  separate `Agent(..., runtime_environment=...)` entry points are removed.
804
+ - Configure cwd once with `ContextConfig(cwd=...)` or `build_agent(cwd=...)`.
805
+ Tools use the required `ToolContext.cwd` for each call; tool constructors
806
+ and `get_tools` do not take a separate default directory.
796
807
  - Each sub-agent gets its own context manager and runtime services. The sub-agent
797
808
  service retains terminal event status and queues one notification per terminal
798
809
  event. The parent consumes these notifications before its next normal model
@@ -309,7 +309,8 @@ pycodex doctor
309
309
  - 交互模式下会显示简洁阶段事件流,例如工具执行状态和模型回看工具结果
310
310
  - 开场保留欢迎语,显示 model、effort、endpoint 和命令列表。
311
311
  - assistant 文本会按流式 delta 直接打印
312
- - 交互模式下支持 `/history`、`/title`、`/model`、`/effort` 和 `/resume`
312
+ - 交互模式下支持 `/history`、`/title`、`/cd`、`/model`、`/effort` 和 `/resume`
313
+ - `/cd <目录>` 在空闲时切换当前 session 的工作目录,`/cd` 查看当前目录;支持相对路径、`~` 和直接输入含空格的路径(不加引号)。之后的工具和上下文使用新目录。
313
314
  - `/model <name>` 会切换当前交互会话后续请求使用的模型;`/model` 会显示当前模型和可选模型
314
315
  - `/effort low` 在空闲时切换 effort,`/effort` 查看当前值,`/effort default` 恢复模型默认值;前端模型标签显示 `model:effort`。
315
316
  - `/resume` 不带参数时会按首条用户消息预览列出当前可恢复的 session;`/resume 1`
@@ -337,6 +338,10 @@ pycodex doctor
337
338
  `enable_board: false` 关闭 Canvas 标签、路由、轮询和上下文注入,会话及文件视图
338
339
  仍照常持久化和恢复。旧 `board: "demo.html"` / `board: false` 配置读取时转换,
339
340
  继续使用原状态文件,后续保存采用新格式。管理页新建时不填前缀会分配 `/tmp/pcws-*`。
341
+ - 每个 session 初始继承 `work_dir`,之后可通过 `/cd` 各自切换。仅当目录与默认值不同时,
342
+ 输入框内显示小型文件夹提示,悬停可查看完整路径;切回默认目录后隐藏。
343
+ 目录随会话索引保存,空会话重启后也能恢复。聊天和 Canvas 中的相对文件链接按当前选中
344
+ session 的目录解析;Canvas 文件及 workspace 状态文件的位置保持固定。
340
345
  - workspace 整个左侧展示区称为看板,包含可选的实时 HTML Canvas 首标签和只读文件/diff 标签;
341
346
  所有标签在同一行共同滚动,溢出时可用鼠标滚轮横向滚动。点击普通 Markdown 文件链接
342
347
  `[源码](src/app.py:42)` 或 `[源码](/data/project/app.py#L42)` 即可打开视图。
@@ -152,6 +152,13 @@ class Agent:
152
152
  self.context_manager.set_model(model)
153
153
  self._last_total_usage_tokens = None
154
154
 
155
+ def set_cwd(self, cwd: "typing.Union[str, Path]") -> "None":
156
+ if self.is_running:
157
+ raise RuntimeError("cannot change directory while agent is running")
158
+ if not self.accepts_input:
159
+ raise RuntimeError("cannot change directory while agent is closing or closed")
160
+ self.context_manager.set_cwd(cwd)
161
+
155
162
  def resume(
156
163
  self, session_file_path: "typing.Union[str, Path, None]" = None
157
164
  ) -> "typing.Union[typing.Dict[str, object], None]":
@@ -400,6 +407,7 @@ class Agent:
400
407
  context = ToolContext(
401
408
  turn_id=turn_id,
402
409
  history=self.history,
410
+ cwd=self.context_manager.cwd,
403
411
  )
404
412
  if len(batch) == 1:
405
413
  results.append(await self._run_single_tool(turn_id, batch[0], context))
@@ -86,7 +86,6 @@ def configure_loguru() -> "None":
86
86
  def get_tools(
87
87
  runtime_environment: "typing.Union[AgentRuntimeEnvironment, None]" = None,
88
88
  exec_mode: "bool" = False,
89
- cwd: "typing.Union[str, Path, None]" = None,
90
89
  toolset: "typing.Union[typing.Iterable[str], None]" = None,
91
90
  ):
92
91
  from .tools import (
@@ -119,8 +118,8 @@ def get_tools(
119
118
 
120
119
  runtime_environment = runtime_environment or create_agent_runtime_environment()
121
120
  registry = Registry(runtime_environment)
122
- code_mode_manager = CodeModeManager(registry, cwd=cwd)
123
- unified_exec_manager = UnifiedExecManager(cwd=cwd)
121
+ code_mode_manager = CodeModeManager(registry)
122
+ unified_exec_manager = UnifiedExecManager()
124
123
  clock_manager = ClockManager()
125
124
  exec_tool = ExecTool(code_mode_manager)
126
125
  wait_tool = WaitTool(code_mode_manager)
@@ -135,16 +134,16 @@ def get_tools(
135
134
  resume_agent_tool = ResumeAgentTool(runtime_environment.subagent_manager)
136
135
  wait_agent_tool = WaitAgentTool(runtime_environment.subagent_manager)
137
136
  close_agent_tool = CloseAgentTool(runtime_environment.subagent_manager)
138
- apply_patch_tool = ApplyPatchTool(cwd=cwd)
139
- shell_tool = ShellTool(cwd=cwd)
140
- shell_command_tool = ShellCommandTool(cwd=cwd)
137
+ apply_patch_tool = ApplyPatchTool()
138
+ shell_tool = ShellTool()
139
+ shell_command_tool = ShellCommandTool()
141
140
  exec_command_tool = ExecCommandTool(unified_exec_manager)
142
141
  write_stdin_tool = WriteStdinTool(unified_exec_manager)
143
142
  clock_tool = ClockTool(clock_manager)
144
- grep_files_tool = GrepFilesTool(cwd=cwd)
143
+ grep_files_tool = GrepFilesTool()
145
144
  read_file_tool = ReadFileTool()
146
145
  list_dir_tool = ListDirTool()
147
- view_image_tool = ViewImageTool(cwd=cwd)
146
+ view_image_tool = ViewImageTool()
148
147
  tools = (
149
148
  shell_tool,
150
149
  shell_command_tool,
@@ -200,7 +199,6 @@ def get_tools(
200
199
 
201
200
  def get_subagent_tools(
202
201
  runtime_environment: "typing.Union[AgentRuntimeEnvironment, None]" = None,
203
- cwd: "typing.Union[str, Path, None]" = None,
204
202
  ):
205
203
  from .tools import (
206
204
  ApplyPatchTool,
@@ -215,13 +213,13 @@ def get_subagent_tools(
215
213
 
216
214
  runtime_environment = runtime_environment or create_agent_runtime_environment()
217
215
  registry = Registry(runtime_environment)
218
- unified_exec_manager = UnifiedExecManager(cwd=cwd)
216
+ unified_exec_manager = UnifiedExecManager()
219
217
  registry.register(ExecCommandTool(unified_exec_manager))
220
218
  registry.register(WriteStdinTool(unified_exec_manager))
221
219
  registry.register(UpdatePlanTool(runtime_environment.plan_store))
222
- registry.register(ApplyPatchTool(cwd=cwd))
220
+ registry.register(ApplyPatchTool())
223
221
  registry.register(WebSearchTool())
224
- registry.register(ViewImageTool(cwd=cwd))
222
+ registry.register(ViewImageTool())
225
223
  return registry
226
224
 
227
225
 
@@ -244,46 +242,41 @@ def build_agent(
244
242
  )
245
243
  runtime_environment = create_agent_runtime_environment()
246
244
 
247
- def make_subagent_runtime_builder(base_client):
245
+ def make_subagent_runtime_builder(parent_agent):
248
246
  def build_subagent_runtime(
249
247
  model_override: "typing.Union[str, None]",
250
248
  reasoning_effort_override: "typing.Union[str, None]",
251
249
  initial_history=(),
252
250
  session_id: "typing.Union[str, None]" = None,
253
251
  ) -> "AgentRuntime":
254
- nested_client = base_client.with_overrides(
252
+ nested_client = parent_agent.model_client.with_overrides(
255
253
  model_override,
256
254
  reasoning_effort_override,
257
255
  session_id=session_id,
258
256
  openai_subagent="collab_spawn",
259
257
  )
260
258
  subagent_agent_runtime_environment = create_agent_runtime_environment()
261
- subagent_agent_runtime_environment.subagent_manager.set_runtime_builder(
262
- make_subagent_runtime_builder(nested_client)
263
- )
259
+ inherited_cwd = parent_agent.context_manager.cwd
264
260
  sub_agent = Agent(
265
261
  nested_client,
266
- get_subagent_tools(
267
- subagent_agent_runtime_environment, cwd=resolved_cwd
268
- ),
269
- context_config,
262
+ get_subagent_tools(subagent_agent_runtime_environment),
263
+ replace(context_config, cwd=inherited_cwd),
270
264
  initial_history=tuple(initial_history),
271
265
  session_id=session_id,
272
266
  )
267
+ subagent_agent_runtime_environment.subagent_manager.set_runtime_builder(
268
+ make_subagent_runtime_builder(sub_agent)
269
+ )
273
270
  return AgentRuntime(sub_agent)
274
271
 
275
272
  return build_subagent_runtime
276
273
 
277
- runtime_environment.subagent_manager.set_runtime_builder(
278
- make_subagent_runtime_builder(client)
279
- )
280
274
  session_id = uuid7_string()
281
- return Agent(
275
+ agent = Agent(
282
276
  client,
283
277
  get_tools(
284
278
  runtime_environment,
285
279
  exec_mode=True,
286
- cwd=resolved_cwd,
287
280
  toolset=toolset,
288
281
  ),
289
282
  context_config,
@@ -292,6 +285,10 @@ def build_agent(
292
285
  ),
293
286
  session_id=session_id,
294
287
  )
288
+ runtime_environment.subagent_manager.set_runtime_builder(
289
+ make_subagent_runtime_builder(agent)
290
+ )
291
+ return agent
295
292
 
296
293
 
297
294
  def build_model(
@@ -244,6 +244,16 @@ class ContextManager:
244
244
  def set_model(self, model: "str") -> "None":
245
245
  self._config = replace(self._config, model=model)
246
246
 
247
+ def set_cwd(self, cwd: "typing.Union[str, Path]") -> "None":
248
+ path = Path(cwd).expanduser()
249
+ path = (self.cwd / path).resolve(strict=True)
250
+ if not path.is_dir():
251
+ raise NotADirectoryError(str(path))
252
+ self.cwd = path
253
+ self._config = replace(self._config, cwd=path)
254
+ self._workspace_metadata_turn_id = None
255
+ self._workspace_metadata_cache = None
256
+
247
257
  def resolve_auto_compact_token_limit(self) -> "typing.Union[int, None]":
248
258
  if self._config.model_auto_compact_token_limit is not None:
249
259
  return self._config.model_auto_compact_token_limit
@@ -124,6 +124,7 @@ class AgentRuntime:
124
124
  "help",
125
125
  "history",
126
126
  "title",
127
+ "cd",
127
128
  "model",
128
129
  "effort",
129
130
  "resume",
@@ -139,6 +140,7 @@ class AgentRuntime:
139
140
  recorded_path = self.agent.recorded_session_file_path
140
141
  return {
141
142
  "session_id": self.agent.session_id,
143
+ "cwd": str(self.agent.context_manager.cwd),
142
144
  "rollout_path": str(rollout_path) if rollout_path is not None else None,
143
145
  "recorded_rollout_path": (
144
146
  str(recorded_path) if recorded_path is not None else None
@@ -187,6 +189,11 @@ class AgentRuntime:
187
189
  self.agent.set_model(model)
188
190
  self.publish_state("model")
189
191
 
192
+ def set_cwd(self, cwd):
193
+ self.require_idle("change directory")
194
+ self.agent.set_cwd(cwd)
195
+ self.publish_state("cwd")
196
+
190
197
  def resume(self, path=None, title=""):
191
198
  self.require_idle("resume")
192
199
  resumed = self.agent.resume(path)
@@ -275,6 +282,10 @@ class AgentRuntime:
275
282
  return {"kind": "help", "commands": self.commands()}
276
283
  if command == "history":
277
284
  return {"kind": "history", "state": self.snapshot()}
285
+ if command == "cd":
286
+ if argument:
287
+ self.set_cwd(argument)
288
+ return {"kind": "cwd", "cwd": str(self.agent.context_manager.cwd)}
278
289
  if command == "title":
279
290
  if argument:
280
291
  self.set_title(argument)
@@ -92,20 +92,15 @@ class ApplyPatchTool(BaseTool):
92
92
  }
93
93
  supports_parallel = False
94
94
 
95
- def __init__(
96
- self, cwd: "typing.Union[typing.Union[str, Path], None]" = None
97
- ) -> "None":
98
- self._workspace_root = Path(cwd or Path.cwd()).resolve()
99
-
100
95
  async def run(self, context: "ToolContext", args: "JSONValue") -> "JSONValue":
101
- del context
96
+ cwd = context.cwd
102
97
  patch_text = str(args)
103
98
  logger.debug(
104
- "apply_patch workspace={} bytes={}", self._workspace_root, len(patch_text)
99
+ "apply_patch workspace={} bytes={}", cwd, len(patch_text)
105
100
  )
106
101
  try:
107
102
  operations = self._parse_patch(patch_text)
108
- return self._format_result(self._apply_operations(operations), exit_code=0)
103
+ return self._format_result(self._apply_operations(operations, cwd), exit_code=0)
109
104
  except ApplyPatchError as exc:
110
105
  return self._format_result(str(exc), exit_code=1)
111
106
 
@@ -239,52 +234,54 @@ class ApplyPatchTool(BaseTool):
239
234
  def _apply_operations(
240
235
  self,
241
236
  operations: "typing.List[typing.Union[typing.Union[_AddFileOp, _DeleteFileOp], _UpdateFileOp]]",
237
+ cwd: "Path",
242
238
  ) -> "str":
243
239
  preview: "typing.Dict[Path, typing.Union[str, None]]" = {}
244
240
  summaries: "typing.Dict[Path, str]" = {}
245
241
 
246
242
  for operation in operations:
247
243
  if isinstance(operation, _AddFileOp):
248
- path = self._resolve_workspace_path(operation.path)
244
+ path = self._resolve_workspace_path(operation.path, cwd)
249
245
  preview[path] = operation.content
250
246
  summaries[path] = "A"
251
247
  continue
252
248
 
253
249
  if isinstance(operation, _DeleteFileOp):
254
- path = self._resolve_workspace_path(operation.path)
255
- self._read_preview_file(path, preview)
250
+ path = self._resolve_workspace_path(operation.path, cwd)
251
+ self._read_preview_file(path, preview, cwd)
256
252
  preview[path] = None
257
253
  summaries[path] = "D"
258
254
  continue
259
255
 
260
- path = self._resolve_workspace_path(operation.path)
261
- original = self._read_preview_file(path, preview)
262
- updated = self._apply_update(path, original, operation.sections)
256
+ path = self._resolve_workspace_path(operation.path, cwd)
257
+ original = self._read_preview_file(path, preview, cwd)
258
+ updated = self._apply_update(path, original, operation.sections, cwd)
263
259
  destination = path
264
260
  if operation.move_to is not None:
265
- destination = self._resolve_workspace_path(operation.move_to)
261
+ destination = self._resolve_workspace_path(operation.move_to, cwd)
266
262
  preview[path] = None
267
263
  summaries.pop(path, None)
268
264
  preview[destination] = updated
269
265
  summaries[destination] = "M"
270
266
 
271
267
  self._write_preview(preview)
272
- return self._format_success(summaries)
268
+ return self._format_success(summaries, cwd)
273
269
 
274
270
  def _read_preview_file(
275
- self, path: "Path", preview: "typing.Dict[Path, typing.Union[str, None]]"
271
+ self, path: "Path", preview: "typing.Dict[Path, typing.Union[str, None]]",
272
+ cwd: "Path",
276
273
  ) -> "str":
277
274
  if path in preview:
278
275
  content = preview[path]
279
276
  if content is None:
280
277
  raise ApplyPatchError(
281
- f"apply_patch verification failed: Failed to read {self._display_path(path)}"
278
+ f"apply_patch verification failed: Failed to read {self._display_path(path, cwd)}"
282
279
  )
283
280
  return content
284
281
 
285
282
  if not path.exists() or not path.is_file():
286
283
  raise ApplyPatchError(
287
- f"apply_patch verification failed: Failed to read {self._display_path(path)}"
284
+ f"apply_patch verification failed: Failed to read {self._display_path(path, cwd)}"
288
285
  )
289
286
  return path.read_text(encoding="utf-8", errors="replace")
290
287
 
@@ -293,6 +290,7 @@ class ApplyPatchTool(BaseTool):
293
290
  path: "Path",
294
291
  original_text: "str",
295
292
  sections: "typing.Tuple[_UpdateSection, ...]",
293
+ cwd: "Path",
296
294
  ) -> "str":
297
295
  lines = original_text.splitlines()
298
296
  cursor = 0
@@ -308,7 +306,7 @@ class ApplyPatchTool(BaseTool):
308
306
  if match_index is None:
309
307
  raise ApplyPatchError(
310
308
  "apply_patch verification failed: Failed to find expected lines in "
311
- f"{self._display_path(path)}"
309
+ f"{self._display_path(path, cwd)}"
312
310
  )
313
311
  lines[match_index : match_index + len(old_block)] = new_block
314
312
  cursor = match_index + len(new_block)
@@ -350,10 +348,10 @@ class ApplyPatchTool(BaseTool):
350
348
  path.parent.mkdir(parents=True, exist_ok=True)
351
349
  path.write_text(content, encoding="utf-8")
352
350
 
353
- def _format_success(self, summaries: "typing.Dict[Path, str]") -> "str":
351
+ def _format_success(self, summaries: "typing.Dict[Path, str]", cwd: "Path") -> "str":
354
352
  buckets = {"A": [], "M": [], "D": []}
355
353
  for path, status in summaries.items():
356
- buckets[status].append(self._display_path(path))
354
+ buckets[status].append(self._display_path(path, cwd))
357
355
  lines = ["Success:"]
358
356
  for status in ("A", "M", "D"):
359
357
  for rel_path in sorted(buckets[status]):
@@ -365,14 +363,14 @@ class ApplyPatchTool(BaseTool):
365
363
  f"Exit code: {exit_code}\n" "Wall time: 0 seconds\n" "Output:\n" f"{output}"
366
364
  )
367
365
 
368
- def _resolve_workspace_path(self, path_text: "str") -> "Path":
366
+ def _resolve_workspace_path(self, path_text: "str", cwd: "Path") -> "Path":
369
367
  path = Path(path_text).expanduser()
370
- resolved = path if path.is_absolute() else self._workspace_root / path
368
+ resolved = path if path.is_absolute() else cwd / path
371
369
  return resolved.resolve()
372
370
 
373
- def _display_path(self, path: "Path") -> "str":
371
+ def _display_path(self, path: "Path", cwd: "Path") -> "str":
374
372
  try:
375
- return path.relative_to(self._workspace_root).as_posix()
373
+ return path.relative_to(cwd).as_posix()
376
374
  except ValueError:
377
375
  return str(path)
378
376
 
@@ -16,6 +16,7 @@ import traceback
16
16
  import typing
17
17
  from abc import ABC, abstractmethod
18
18
  from dataclasses import dataclass
19
+ from pathlib import Path
19
20
 
20
21
  from ..events import Event
21
22
  from ..protocol import (
@@ -40,6 +41,10 @@ if typing.TYPE_CHECKING:
40
41
  class ToolContext:
41
42
  turn_id: "str"
42
43
  history: "typing.Tuple[ConversationItem, ...]"
44
+ cwd: "Path"
45
+
46
+ def resolve_path(self, path=None) -> "Path":
47
+ return (self.cwd / Path("." if path is None else path).expanduser()).resolve()
43
48
 
44
49
 
45
50
  class StructuredToolOutput:
@@ -60,13 +60,8 @@ class ParsedExecSource:
60
60
 
61
61
 
62
62
  class CodeModeManager:
63
- def __init__(
64
- self,
65
- registry: "ToolRegistry",
66
- cwd: "typing.Union[typing.Union[str, Path], None]" = None,
67
- ) -> "None":
63
+ def __init__(self, registry: "ToolRegistry") -> "None":
68
64
  self._registry = registry
69
- self._default_cwd = Path(cwd or Path.cwd()).resolve()
70
65
  self._runtime_script = Path(__file__).with_name("exec_runtime.js")
71
66
  self._stored_values: "typing.Dict[str, JSONValue]" = {}
72
67
  self._cells: "typing.Dict[str, ExecCell]" = {}
@@ -130,7 +125,7 @@ class CodeModeManager:
130
125
  process = await asyncio.create_subprocess_exec(
131
126
  "node",
132
127
  str(self._runtime_script),
133
- cwd=str(self._default_cwd),
128
+ cwd=str(context.cwd),
134
129
  stdin=asyncio.subprocess.PIPE,
135
130
  stdout=asyncio.subprocess.PIPE,
136
131
  stderr=asyncio.subprocess.PIPE,
@@ -153,7 +148,7 @@ class CodeModeManager:
153
148
  "tools": self.enabled_tools(),
154
149
  },
155
150
  )
156
- logger.debug("exec start cell_id={} cwd={}", cell_id, self._default_cwd)
151
+ logger.debug("exec start cell_id={} cwd={}", cell_id, context.cwd)
157
152
  return cell
158
153
 
159
154
  async def _read_stdout(self, cell: "ExecCell", context: "ToolContext") -> "None":
@@ -278,7 +273,7 @@ class CodeModeManager:
278
273
  arguments=message.get("arguments"),
279
274
  tool_type=tool.tool_type,
280
275
  ),
281
- ToolContext(turn_id=context.turn_id, history=context.history),
276
+ context,
282
277
  )
283
278
  if result.is_error:
284
279
  payload = {
@@ -87,14 +87,13 @@ class ExecCommandTool(BaseTool):
87
87
  return self._manager.running_session_count()
88
88
 
89
89
  async def run(self, context: "ToolContext", args: "JSONDict") -> "JSONValue":
90
- del context
91
90
  cmd = str(args.get("cmd", "")).strip()
92
91
  if not cmd:
93
92
  return "Error: `cmd` is required."
94
93
 
95
94
  return await self._manager.exec_command(
96
95
  cmd=cmd,
97
- workdir=self._optional_string(args, "workdir"),
96
+ cwd=context.resolve_path(args.get("workdir")),
98
97
  shell=self._optional_string(args, "shell"),
99
98
  login=bool(args.get("login", DEFAULT_LOGIN)),
100
99
  tty=bool(args.get("tty", DEFAULT_TTY)),
@@ -53,18 +53,12 @@ class GrepFilesTool(BaseTool):
53
53
  "additionalProperties": False,
54
54
  }
55
55
 
56
- def __init__(
57
- self, cwd: "typing.Union[typing.Union[str, Path], None]" = None
58
- ) -> "None":
59
- self._working_directory = Path(cwd or Path.cwd()).resolve()
60
-
61
56
  async def run(self, context: "ToolContext", args: "JSONDict") -> "JSONValue":
62
- del context
63
57
  pattern = str(args.get("pattern", "")).strip()
64
58
  include = str(args.get("include", "")).strip() or None
65
59
  limit = min(int(args.get("limit", DEFAULT_LIMIT)), MAX_LIMIT)
66
60
  path_arg = args.get("path")
67
- search_path = self._resolve_path(path_arg)
61
+ search_path = context.resolve_path(path_arg)
68
62
 
69
63
  if not pattern:
70
64
  return "Error: `pattern` must not be empty."
@@ -88,7 +82,7 @@ class GrepFilesTool(BaseTool):
88
82
  try:
89
83
  process = await asyncio.create_subprocess_exec(
90
84
  *command,
91
- cwd=str(self._working_directory),
85
+ cwd=str(context.cwd),
92
86
  stdout=asyncio.subprocess.PIPE,
93
87
  stderr=asyncio.subprocess.PIPE,
94
88
  )
@@ -154,11 +148,3 @@ class GrepFilesTool(BaseTool):
154
148
 
155
149
  matches.sort(key=lambda path: path.stat().st_mtime, reverse=True)
156
150
  return [str(path) for path in matches[:limit]]
157
-
158
- def _resolve_path(self, path_arg) -> "Path":
159
- if path_arg in (None, ""):
160
- return self._working_directory
161
- path = Path(str(path_arg))
162
- if not path.is_absolute():
163
- path = self._working_directory / path
164
- return path.resolve()
@@ -12,8 +12,6 @@ Expected behavior:
12
12
  """
13
13
 
14
14
  import asyncio
15
- import typing
16
- from pathlib import Path
17
15
 
18
16
  from ..protocol import JSONDict, JSONValue
19
17
  from .base_tool import BaseTool, ToolContext
@@ -54,13 +52,7 @@ class ShellCommandTool(BaseTool):
54
52
  }
55
53
  supports_parallel = False
56
54
 
57
- def __init__(
58
- self, cwd: "typing.Union[typing.Union[str, Path], None]" = None
59
- ) -> "None":
60
- self._working_directory = Path(cwd or Path.cwd()).resolve()
61
-
62
55
  async def run(self, context: "ToolContext", args: "JSONDict") -> "JSONValue":
63
- del context
64
56
  command = str(args.get("command", "")).strip()
65
57
  timeout_ms = int(args.get("timeout_ms", DEFAULT_SHELL_TIMEOUT_MS))
66
58
  if not command:
@@ -68,7 +60,7 @@ class ShellCommandTool(BaseTool):
68
60
 
69
61
  login = bool(args.get("login", True))
70
62
  workdir_arg = args.get("workdir")
71
- working_directory = self._resolve_workdir(workdir_arg)
63
+ working_directory = context.resolve_path(workdir_arg)
72
64
  shell_args = ["bash", "-lc" if login else "-c", command]
73
65
 
74
66
  process = await asyncio.create_subprocess_exec(
@@ -111,14 +103,6 @@ class ShellCommandTool(BaseTool):
111
103
 
112
104
  return "\n".join(pieces)
113
105
 
114
- def _resolve_workdir(self, workdir_arg) -> "Path":
115
- if workdir_arg in (None, ""):
116
- return self._working_directory
117
- workdir = Path(str(workdir_arg))
118
- if not workdir.is_absolute():
119
- workdir = self._working_directory / workdir
120
- return workdir.resolve()
121
-
122
106
  def _clip_output(self, text: "str") -> "str":
123
107
  if len(text) <= MAX_OUTPUT_CHARS:
124
108
  return text
@@ -12,8 +12,6 @@ Expected behavior:
12
12
  """
13
13
 
14
14
  import asyncio
15
- import typing
16
- from pathlib import Path
17
15
 
18
16
  from ..protocol import JSONDict, JSONValue
19
17
  from .base_tool import BaseTool, ToolContext
@@ -51,13 +49,7 @@ class ShellTool(BaseTool):
51
49
  }
52
50
  supports_parallel = False
53
51
 
54
- def __init__(
55
- self, cwd: "typing.Union[typing.Union[str, Path], None]" = None
56
- ) -> "None":
57
- self._working_directory = Path(cwd or Path.cwd()).resolve()
58
-
59
52
  async def run(self, context: "ToolContext", args: "JSONDict") -> "JSONValue":
60
- del context
61
53
  command = args.get("command")
62
54
  timeout_ms = int(args.get("timeout_ms", DEFAULT_SHELL_TIMEOUT_MS))
63
55
  if not isinstance(command, list) or not command:
@@ -66,7 +58,7 @@ class ShellTool(BaseTool):
66
58
  return "Error: each `command` entry must be a non-empty string."
67
59
 
68
60
  workdir_arg = args.get("workdir")
69
- working_directory = self._resolve_workdir(workdir_arg)
61
+ working_directory = context.resolve_path(workdir_arg)
70
62
 
71
63
  process = await asyncio.create_subprocess_exec(
72
64
  *command,
@@ -108,14 +100,6 @@ class ShellTool(BaseTool):
108
100
 
109
101
  return "\n".join(pieces)
110
102
 
111
- def _resolve_workdir(self, workdir_arg) -> "Path":
112
- if workdir_arg in (None, ""):
113
- return self._working_directory
114
- workdir = Path(str(workdir_arg))
115
- if not workdir.is_absolute():
116
- workdir = self._working_directory / workdir
117
- return workdir.resolve()
118
-
119
103
  def _clip_output(self, text: "str") -> "str":
120
104
  if len(text) <= MAX_OUTPUT_CHARS:
121
105
  return text
@@ -121,10 +121,7 @@ class UnifiedExecSession:
121
121
 
122
122
 
123
123
  class UnifiedExecManager:
124
- def __init__(
125
- self, cwd: "typing.Union[typing.Union[str, Path], None]" = None
126
- ) -> "None":
127
- self._default_cwd = Path(cwd or Path.cwd()).resolve()
124
+ def __init__(self) -> "None":
128
125
  self._next_session_id = DEFAULT_SESSION_ID_START
129
126
  self._sessions: "typing.Dict[int, UnifiedExecSession]" = {}
130
127
  self._lock = threading.Lock()
@@ -146,7 +143,7 @@ class UnifiedExecManager:
146
143
  async def exec_command(
147
144
  self,
148
145
  cmd: "str",
149
- workdir: "typing.Union[str, None]" = None,
146
+ cwd: "Path",
150
147
  shell: "typing.Union[str, None]" = None,
151
148
  login: "bool" = DEFAULT_LOGIN,
152
149
  tty: "bool" = DEFAULT_TTY,
@@ -155,7 +152,6 @@ class UnifiedExecManager:
155
152
  ) -> "str":
156
153
  session_id = await self._allocate_session_id()
157
154
  command = self._build_shell_command(cmd, shell, login)
158
- cwd = self._resolve_workdir(workdir)
159
155
  logger.debug(
160
156
  "exec_command start session_id={} shell_command={} cwd={}",
161
157
  session_id,
@@ -381,14 +377,6 @@ class UnifiedExecManager:
381
377
  }
382
378
  )
383
379
 
384
- def _resolve_workdir(self, workdir: "typing.Union[str, None]") -> "Path":
385
- if not workdir:
386
- return self._default_cwd
387
- path = Path(workdir)
388
- if not path.is_absolute():
389
- path = self._default_cwd / path
390
- return path.resolve()
391
-
392
380
  def _build_shell_command(
393
381
  self,
394
382
  cmd: "str",
@@ -15,9 +15,7 @@ Expected behavior:
15
15
  item that Codex uses when feeding image tool output back to the model.
16
16
  """
17
17
 
18
- import typing
19
18
  from importlib.util import find_spec
20
- from pathlib import Path
21
19
 
22
20
  from ..protocol import JSONDict, JSONValue
23
21
  from ..utils.image_utils import ImageProcessingError, load_image_data_url
@@ -65,13 +63,7 @@ class ViewImageTool(BaseTool):
65
63
  }
66
64
  output_schema = VIEW_IMAGE_OUTPUT_SCHEMA
67
65
 
68
- def __init__(
69
- self, cwd: "typing.Union[typing.Union[str, Path], None]" = None
70
- ) -> "None":
71
- self._workspace_root = Path(cwd or Path.cwd()).resolve()
72
-
73
66
  async def run(self, context: "ToolContext", args: "JSONDict") -> "JSONValue":
74
- del context
75
67
  path_value = str(args.get("path", "")).strip()
76
68
  if not path_value:
77
69
  return "Error: `path` is required."
@@ -88,10 +80,7 @@ class ViewImageTool(BaseTool):
88
80
  f"`{detail_value}`."
89
81
  )
90
82
 
91
- path = Path(path_value)
92
- if not path.is_absolute():
93
- path = self._workspace_root / path
94
- path = path.resolve()
83
+ path = context.resolve_path(path_value)
95
84
  if not path.exists():
96
85
  return f"Error: unable to locate image at `{path}`."
97
86
  if not path.is_file():
@@ -26,6 +26,8 @@ def format_command_result(result: "typing.Dict[str, object]") -> "str":
26
26
  return "Extra commands: " + ", ".join("/" + name for name in result["commands"])
27
27
  if kind in {"title", "title_changed"}:
28
28
  return "Session: " + (result["title"] or "untitled")
29
+ if kind == "cwd":
30
+ return "Current directory: " + result["cwd"]
29
31
  if kind == "models":
30
32
  return (
31
33
  "Current model: "
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-codex"
7
- version = "0.4.7"
7
+ version = "0.4.8"
8
8
  description = "A minimal Python extraction of Codex's main agent loop"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.6.2"
@@ -40,7 +40,7 @@ test = [
40
40
  ]
41
41
  dev = [
42
42
  { include-group = "test" },
43
- "pycodex-ws==0.4.7",
43
+ "pycodex-ws==0.4.8",
44
44
  "black>=22.8.0",
45
45
  "debugpy>=1.8.20; python_version >= '3.8'",
46
46
  "isort>=5.10.1",
File without changes
File without changes