python-codex 0.2.2__tar.gz → 0.2.3__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 (124) hide show
  1. {python_codex-0.2.2 → python_codex-0.2.3}/PKG-INFO +21 -4
  2. {python_codex-0.2.2 → python_codex-0.2.3}/README.md +20 -3
  3. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/cli.py +21 -13
  4. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/context.py +4 -1
  5. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/feishu_link.py +32 -14
  6. {python_codex-0.2.2 → python_codex-0.2.3}/pyproject.toml +5 -2
  7. {python_codex-0.2.2 → python_codex-0.2.3}/tests/test_cli.py +17 -0
  8. {python_codex-0.2.2 → python_codex-0.2.3}/tests/test_context.py +12 -0
  9. {python_codex-0.2.2 → python_codex-0.2.3}/tests/test_feishu_link.py +98 -6
  10. {python_codex-0.2.2 → python_codex-0.2.3}/tests/test_workspace_server.py +436 -36
  11. python_codex-0.2.3/workspace_server/__init__.py +39 -0
  12. {python_codex-0.2.2 → python_codex-0.2.3}/workspace_server/app.py +546 -373
  13. {python_codex-0.2.2 → python_codex-0.2.3}/workspace_server/workspace.html +53 -3
  14. python_codex-0.2.3/workspace_server/workspaces.html +551 -0
  15. python_codex-0.2.3/workspace_server/workspaces.py +561 -0
  16. python_codex-0.2.2/workspace_server/__init__.py +0 -23
  17. {python_codex-0.2.2 → python_codex-0.2.3}/.github/workflows/publish.yml +0 -0
  18. {python_codex-0.2.2 → python_codex-0.2.3}/.github/workflows/test.yml +0 -0
  19. {python_codex-0.2.2 → python_codex-0.2.3}/.gitignore +0 -0
  20. {python_codex-0.2.2 → python_codex-0.2.3}/AGENTS.md +0 -0
  21. {python_codex-0.2.2 → python_codex-0.2.3}/LICENSE +0 -0
  22. {python_codex-0.2.2 → python_codex-0.2.3}/README_ZH.md +0 -0
  23. {python_codex-0.2.2 → python_codex-0.2.3}/docs/ALIGNMENT.md +0 -0
  24. {python_codex-0.2.2 → python_codex-0.2.3}/docs/CONTEXT.md +0 -0
  25. {python_codex-0.2.2 → python_codex-0.2.3}/docs/responses_server/README.md +0 -0
  26. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/__init__.py +0 -0
  27. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/agent.py +0 -0
  28. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/collaboration.py +0 -0
  29. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/compat.py +0 -0
  30. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/doctor.py +0 -0
  31. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/feishu_card.py +0 -0
  32. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/interactive_session.py +0 -0
  33. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/model.py +0 -0
  34. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/portable.py +0 -0
  35. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/portable_server.py +0 -0
  36. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/prompts/collaboration_default.md +0 -0
  37. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/prompts/collaboration_plan.md +0 -0
  38. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/prompts/default_base_instructions.md +0 -0
  39. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/prompts/models.json +0 -0
  40. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/prompts/permissions/approval_policy/never.md +0 -0
  41. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/prompts/permissions/approval_policy/on_failure.md +0 -0
  42. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/prompts/permissions/approval_policy/on_request.md +0 -0
  43. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/prompts/permissions/approval_policy/on_request_rule_request_permission.md +0 -0
  44. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/prompts/permissions/approval_policy/unless_trusted.md +0 -0
  45. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/prompts/permissions/sandbox_mode/danger_full_access.md +0 -0
  46. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/prompts/permissions/sandbox_mode/read_only.md +0 -0
  47. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/prompts/permissions/sandbox_mode/workspace_write.md +0 -0
  48. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/protocol.py +0 -0
  49. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/runtime.py +0 -0
  50. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/runtime_services.py +0 -0
  51. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/__init__.py +0 -0
  52. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/agent_tool_schemas.py +0 -0
  53. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/apply_patch_tool.py +0 -0
  54. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/base_tool.py +0 -0
  55. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/close_agent_tool.py +0 -0
  56. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/code_mode_manager.py +0 -0
  57. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/exec_command_tool.py +0 -0
  58. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/exec_runtime.js +0 -0
  59. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/exec_tool.py +0 -0
  60. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/grep_files_tool.py +0 -0
  61. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/ipython_tool.py +0 -0
  62. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/list_dir_tool.py +0 -0
  63. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/read_file_tool.py +0 -0
  64. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/request_permissions_tool.py +0 -0
  65. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/request_user_input_tool.py +0 -0
  66. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/resume_agent_tool.py +0 -0
  67. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/send_input_tool.py +0 -0
  68. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/shell_command_tool.py +0 -0
  69. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/shell_tool.py +0 -0
  70. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/spawn_agent_tool.py +0 -0
  71. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/unified_exec_manager.py +0 -0
  72. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/update_plan_tool.py +0 -0
  73. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/view_image_tool.py +0 -0
  74. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/wait_agent_tool.py +0 -0
  75. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/wait_tool.py +0 -0
  76. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/web_search_tool.py +0 -0
  77. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/tools/write_stdin_tool.py +0 -0
  78. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/utils/__init__.py +0 -0
  79. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/utils/async_bridge.py +0 -0
  80. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/utils/compactor.py +0 -0
  81. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/utils/debug.py +0 -0
  82. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/utils/dotenv.py +0 -0
  83. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/utils/get_env.py +0 -0
  84. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/utils/random_ids.py +0 -0
  85. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/utils/session_persist.py +0 -0
  86. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/utils/toolcall_visualize.py +0 -0
  87. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/utils/truncation.py +0 -0
  88. {python_codex-0.2.2 → python_codex-0.2.3}/pycodex/utils/visualize.py +0 -0
  89. {python_codex-0.2.2 → python_codex-0.2.3}/responses_server/__init__.py +0 -0
  90. {python_codex-0.2.2 → python_codex-0.2.3}/responses_server/__main__.py +0 -0
  91. {python_codex-0.2.2 → python_codex-0.2.3}/responses_server/app.py +0 -0
  92. {python_codex-0.2.2 → python_codex-0.2.3}/responses_server/config.py +0 -0
  93. {python_codex-0.2.2 → python_codex-0.2.3}/responses_server/messages_api.py +0 -0
  94. {python_codex-0.2.2 → python_codex-0.2.3}/responses_server/payload_processors.py +0 -0
  95. {python_codex-0.2.2 → python_codex-0.2.3}/responses_server/server.py +0 -0
  96. {python_codex-0.2.2 → python_codex-0.2.3}/responses_server/session_store.py +0 -0
  97. {python_codex-0.2.2 → python_codex-0.2.3}/responses_server/stream_router.py +0 -0
  98. {python_codex-0.2.2 → python_codex-0.2.3}/responses_server/tools/__init__.py +0 -0
  99. {python_codex-0.2.2 → python_codex-0.2.3}/responses_server/tools/custom_adapter.py +0 -0
  100. {python_codex-0.2.2 → python_codex-0.2.3}/responses_server/tools/web_search.py +0 -0
  101. {python_codex-0.2.2 → python_codex-0.2.3}/responses_server/trajectory_dump.py +0 -0
  102. {python_codex-0.2.2 → python_codex-0.2.3}/tests/TESTS.md +0 -0
  103. {python_codex-0.2.2 → python_codex-0.2.3}/tests/__init__.py +0 -0
  104. {python_codex-0.2.2 → python_codex-0.2.3}/tests/compare_request_user_input_roundtrip.py +0 -0
  105. {python_codex-0.2.2 → python_codex-0.2.3}/tests/compare_steer_request_bodies.py +0 -0
  106. {python_codex-0.2.2 → python_codex-0.2.3}/tests/compare_tool_schemas.py +0 -0
  107. {python_codex-0.2.2 → python_codex-0.2.3}/tests/fake_responses_server.py +0 -0
  108. {python_codex-0.2.2 → python_codex-0.2.3}/tests/fakes.py +0 -0
  109. {python_codex-0.2.2 → python_codex-0.2.3}/tests/responses_server/fake_chat_completions_server.py +0 -0
  110. {python_codex-0.2.2 → python_codex-0.2.3}/tests/responses_server/test_server.py +0 -0
  111. {python_codex-0.2.2 → python_codex-0.2.3}/tests/test_agent.py +0 -0
  112. {python_codex-0.2.2 → python_codex-0.2.3}/tests/test_async_bridge.py +0 -0
  113. {python_codex-0.2.2 → python_codex-0.2.3}/tests/test_builtin_tools.py +0 -0
  114. {python_codex-0.2.2 → python_codex-0.2.3}/tests/test_compactor.py +0 -0
  115. {python_codex-0.2.2 → python_codex-0.2.3}/tests/test_doctor.py +0 -0
  116. {python_codex-0.2.2 → python_codex-0.2.3}/tests/test_fake_responses_server.py +0 -0
  117. {python_codex-0.2.2 → python_codex-0.2.3}/tests/test_feishu_card.py +0 -0
  118. {python_codex-0.2.2 → python_codex-0.2.3}/tests/test_ipython_tool.py +0 -0
  119. {python_codex-0.2.2 → python_codex-0.2.3}/tests/test_model.py +0 -0
  120. {python_codex-0.2.2 → python_codex-0.2.3}/tests/test_portable.py +0 -0
  121. {python_codex-0.2.2 → python_codex-0.2.3}/tests/test_py36_syntax.py +0 -0
  122. {python_codex-0.2.2 → python_codex-0.2.3}/tests/test_visualize.py +0 -0
  123. {python_codex-0.2.2 → python_codex-0.2.3}/tools/feishu_oauth.py +0 -0
  124. {python_codex-0.2.2 → python_codex-0.2.3}/workspace_server/__main__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-codex
3
- Version: 0.2.2
3
+ Version: 0.2.3
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
@@ -165,15 +165,16 @@ pycodex --put @127.0.0.1:5577
165
165
  pycodex --put /data/.codex/@127.0.0.1:5577
166
166
  pycodex --call SECRET-CALLID@127.0.0.1:5577 "Reply with exactly OK."
167
167
  pycodex doctor
168
- pycodex-ws --listen 0.0.0.0:6007 --board ./board.html
168
+ pycodex-ws --listen 0.0.0.0:6007 --workspace-config ./workspaces.json
169
+ pycodex-ws --listen 0.0.0.0:6007 --workspace-config ./workspaces.json --password 12345
169
170
  ```
170
171
 
171
172
  Current behavior:
172
173
 
173
174
  - with no argv prompt and a TTY stdin, enter interactive mode
174
175
  - with an argv prompt or piped stdin, run a single turn
175
- - `pycodex-ws` starts the standalone browser workspace with a board pane and a
176
- pycodex session pane
176
+ - `pycodex-ws` starts the standalone browser workspace manager and serves each
177
+ workspace with a board pane and a pycodex session pane
177
178
  - interactive mode supports `/exit` and `/quit`
178
179
  - interactive mode shows a compact event stream for user-visible phases such as
179
180
  tool execution and model follow-up after tool results
@@ -204,6 +205,22 @@ Current behavior:
204
205
  non-persisted follow-up session and submits the file contents as the next
205
206
  user instruction; this is intended for side-effect follow-ups such as
206
207
  Feishu notifications
208
+ - `/link <feishu-email|open_id|chat_id>` attaches the current interactive
209
+ session to a Feishu card; multiple sessions in the same pycodex process share
210
+ one Feishu long-connection listener and route card actions by message id
211
+ - `pycodex-ws --workspace-config workspaces.json` serves workspace boards from
212
+ one process. The JSON file can be either a list or
213
+ `{"workspaces": [...]}`; each entry uses `board` and `work_dir`, with optional
214
+ display/URL `id`, and is exposed at `/w/<id>/`. Internally the resolved
215
+ `board` path is the workspace identity, so adding the same board path twice is
216
+ rejected even with a different name. Relative `board` and `work_dir` paths
217
+ resolve from the config file directory. The root path `/` shows a workspaces
218
+ management page with `add_workspace(name=None, dir="./", board=None)` and
219
+ `delete(name)` controls; omitted names become `workspace-1`, `workspace-2`,
220
+ etc. If `board` is omitted when adding a workspace, pycodex assigns a random
221
+ writable `/tmp/pcws-*.html` board path. Add/delete actions and later
222
+ session-state saves refresh the JSON file. `--password <value>` enables a
223
+ password-only login page for workspace pages, APIs, and websocket connections.
207
224
  - steer is enabled by default in interactive mode: normal input goes into the
208
225
  runtime steer path, the current request stops at the next safe boundary, and
209
226
  later steer text is appended to the next model request's `input` in order;
@@ -144,15 +144,16 @@ pycodex --put @127.0.0.1:5577
144
144
  pycodex --put /data/.codex/@127.0.0.1:5577
145
145
  pycodex --call SECRET-CALLID@127.0.0.1:5577 "Reply with exactly OK."
146
146
  pycodex doctor
147
- pycodex-ws --listen 0.0.0.0:6007 --board ./board.html
147
+ pycodex-ws --listen 0.0.0.0:6007 --workspace-config ./workspaces.json
148
+ pycodex-ws --listen 0.0.0.0:6007 --workspace-config ./workspaces.json --password 12345
148
149
  ```
149
150
 
150
151
  Current behavior:
151
152
 
152
153
  - with no argv prompt and a TTY stdin, enter interactive mode
153
154
  - with an argv prompt or piped stdin, run a single turn
154
- - `pycodex-ws` starts the standalone browser workspace with a board pane and a
155
- pycodex session pane
155
+ - `pycodex-ws` starts the standalone browser workspace manager and serves each
156
+ workspace with a board pane and a pycodex session pane
156
157
  - interactive mode supports `/exit` and `/quit`
157
158
  - interactive mode shows a compact event stream for user-visible phases such as
158
159
  tool execution and model follow-up after tool results
@@ -183,6 +184,22 @@ Current behavior:
183
184
  non-persisted follow-up session and submits the file contents as the next
184
185
  user instruction; this is intended for side-effect follow-ups such as
185
186
  Feishu notifications
187
+ - `/link <feishu-email|open_id|chat_id>` attaches the current interactive
188
+ session to a Feishu card; multiple sessions in the same pycodex process share
189
+ one Feishu long-connection listener and route card actions by message id
190
+ - `pycodex-ws --workspace-config workspaces.json` serves workspace boards from
191
+ one process. The JSON file can be either a list or
192
+ `{"workspaces": [...]}`; each entry uses `board` and `work_dir`, with optional
193
+ display/URL `id`, and is exposed at `/w/<id>/`. Internally the resolved
194
+ `board` path is the workspace identity, so adding the same board path twice is
195
+ rejected even with a different name. Relative `board` and `work_dir` paths
196
+ resolve from the config file directory. The root path `/` shows a workspaces
197
+ management page with `add_workspace(name=None, dir="./", board=None)` and
198
+ `delete(name)` controls; omitted names become `workspace-1`, `workspace-2`,
199
+ etc. If `board` is omitted when adding a workspace, pycodex assigns a random
200
+ writable `/tmp/pcws-*.html` board path. Add/delete actions and later
201
+ session-state saves refresh the JSON file. `--password <value>` enables a
202
+ password-only login page for workspace pages, APIs, and websocket connections.
186
203
  - steer is enabled by default in interactive mode: normal input goes into the
187
204
  runtime steer path, the current request stops at the next safe boundary, and
188
205
  later steer text is appended to the next model request's `input` in order;
@@ -165,6 +165,7 @@ def resolve_prompt_text(prompt_parts: 'Sequence[str]') -> 'str':
165
165
  def get_tools(
166
166
  runtime_environment: 'typing.Union[AgentRuntimeEnvironment, None]' = None,
167
167
  exec_mode: 'bool' = False,
168
+ cwd: 'typing.Union[str, Path, None]' = None,
168
169
  ):
169
170
  from .tools import (
170
171
  ApplyPatchTool,
@@ -194,8 +195,8 @@ def get_tools(
194
195
 
195
196
  runtime_environment = runtime_environment or create_agent_runtime_environment()
196
197
  registry = Registry()
197
- code_mode_manager = CodeModeManager(registry)
198
- unified_exec_manager = UnifiedExecManager()
198
+ code_mode_manager = CodeModeManager(registry, cwd=cwd)
199
+ unified_exec_manager = UnifiedExecManager(cwd=cwd)
199
200
  exec_tool = ExecTool(code_mode_manager)
200
201
  wait_tool = WaitTool(code_mode_manager)
201
202
  web_search_tool = WebSearchTool()
@@ -211,15 +212,15 @@ def get_tools(
211
212
  resume_agent_tool = ResumeAgentTool(runtime_environment.subagent_manager)
212
213
  wait_agent_tool = WaitAgentTool(runtime_environment.subagent_manager)
213
214
  close_agent_tool = CloseAgentTool(runtime_environment.subagent_manager)
214
- apply_patch_tool = ApplyPatchTool()
215
- shell_tool = ShellTool()
216
- shell_command_tool = ShellCommandTool()
215
+ apply_patch_tool = ApplyPatchTool(cwd=cwd)
216
+ shell_tool = ShellTool(cwd=cwd)
217
+ shell_command_tool = ShellCommandTool(cwd=cwd)
217
218
  exec_command_tool = ExecCommandTool(unified_exec_manager)
218
219
  write_stdin_tool = WriteStdinTool(unified_exec_manager)
219
- grep_files_tool = GrepFilesTool()
220
+ grep_files_tool = GrepFilesTool(cwd=cwd)
220
221
  read_file_tool = ReadFileTool()
221
222
  list_dir_tool = ListDirTool()
222
- view_image_tool = ViewImageTool()
223
+ view_image_tool = ViewImageTool(cwd=cwd)
223
224
  if exec_mode:
224
225
  registry.register(exec_command_tool)
225
226
  registry.register(write_stdin_tool)
@@ -258,7 +259,10 @@ def get_tools(
258
259
  return registry
259
260
 
260
261
 
261
- def get_subagent_tools(runtime_environment: 'typing.Union[AgentRuntimeEnvironment, None]' = None):
262
+ def get_subagent_tools(
263
+ runtime_environment: 'typing.Union[AgentRuntimeEnvironment, None]' = None,
264
+ cwd: 'typing.Union[str, Path, None]' = None,
265
+ ):
262
266
  from .tools import (
263
267
  ApplyPatchTool,
264
268
  ExecCommandTool,
@@ -272,13 +276,13 @@ def get_subagent_tools(runtime_environment: 'typing.Union[AgentRuntimeEnvironmen
272
276
 
273
277
  runtime_environment = runtime_environment or create_agent_runtime_environment()
274
278
  registry = Registry()
275
- unified_exec_manager = UnifiedExecManager()
279
+ unified_exec_manager = UnifiedExecManager(cwd=cwd)
276
280
  registry.register(ExecCommandTool(unified_exec_manager))
277
281
  registry.register(WriteStdinTool(unified_exec_manager))
278
282
  registry.register(UpdatePlanTool(runtime_environment.plan_store))
279
- registry.register(ApplyPatchTool())
283
+ registry.register(ApplyPatchTool(cwd=cwd))
280
284
  registry.register(WebSearchTool())
281
- registry.register(ViewImageTool())
285
+ registry.register(ViewImageTool(cwd=cwd))
282
286
  return registry
283
287
 
284
288
 
@@ -290,8 +294,10 @@ def build_agent(
290
294
  session_mode: 'CliSessionMode' = "exec",
291
295
  collaboration_mode: 'CollaborationMode' = DEFAULT_COLLABORATION_MODE,
292
296
  extra_contextual_user_messages: 'typing.Iterable[str]' = (),
297
+ cwd: 'typing.Union[str, Path, None]' = None,
293
298
  ) -> 'Agent':
294
299
  config_path = str(config_path)
300
+ resolved_cwd = Path(cwd or Path.cwd()).resolve()
295
301
  context_manager = ContextManager.from_codex_config(
296
302
  config_path,
297
303
  profile,
@@ -299,6 +305,7 @@ def build_agent(
299
305
  collaboration_mode=collaboration_mode,
300
306
  include_collaboration_instructions=session_mode == "tui",
301
307
  extra_contextual_user_messages=extra_contextual_user_messages,
308
+ cwd=resolved_cwd,
302
309
  )
303
310
  session_id = getattr(client, "_session_id", None) or uuid7_string()
304
311
  if hasattr(client, "_session_id"):
@@ -309,6 +316,7 @@ def build_agent(
309
316
  base_instructions_override=system_prompt,
310
317
  include_collaboration_instructions=False,
311
318
  extra_contextual_user_messages=extra_contextual_user_messages,
319
+ cwd=resolved_cwd,
312
320
  )
313
321
  runtime_environment = create_agent_runtime_environment()
314
322
  runtime_environment.request_user_input_manager.set_handler(None)
@@ -343,7 +351,7 @@ def build_agent(
343
351
  )
344
352
  sub_agent = Agent(
345
353
  nested_client,
346
- get_subagent_tools(subagent_agent_runtime_environment),
354
+ get_subagent_tools(subagent_agent_runtime_environment, cwd=resolved_cwd),
347
355
  subagent_context_manager,
348
356
  initial_history=tuple(initial_history),
349
357
  runtime_environment=subagent_agent_runtime_environment,
@@ -357,7 +365,7 @@ def build_agent(
357
365
  )
358
366
  return Agent(
359
367
  client,
360
- get_tools(runtime_environment, exec_mode=True),
368
+ get_tools(runtime_environment, exec_mode=True, cwd=resolved_cwd),
361
369
  context_manager,
362
370
  rollout_recorder=rollout_recorder,
363
371
  runtime_environment=runtime_environment,
@@ -150,8 +150,9 @@ class ContextManager:
150
150
  include_skills_instructions: 'bool' = True,
151
151
  network_access: 'str' = "enabled",
152
152
  extra_contextual_user_messages: 'typing.Iterable[str]' = (),
153
+ cwd: 'typing.Union[str, Path, None]' = None,
153
154
  ) -> 'None':
154
- self.cwd = Path.cwd().resolve()
155
+ self.cwd = Path(cwd or Path.cwd()).resolve()
155
156
  self._shell = get_shell_name()
156
157
  self._current_date = datetime.now().date().isoformat()
157
158
  self._timezone_name = get_timezone_name()
@@ -193,6 +194,7 @@ class ContextManager:
193
194
  include_skills_instructions: 'bool' = True,
194
195
  network_access: 'str' = "enabled",
195
196
  extra_contextual_user_messages: 'typing.Iterable[str]' = (),
197
+ cwd: 'typing.Union[str, Path, None]' = None,
196
198
  ) -> 'ContextManager':
197
199
  config = ContextConfig.from_codex_config(config_path, profile)
198
200
  return cls(
@@ -204,6 +206,7 @@ class ContextManager:
204
206
  include_skills_instructions=include_skills_instructions,
205
207
  network_access=network_access,
206
208
  extra_contextual_user_messages=extra_contextual_user_messages,
209
+ cwd=cwd,
207
210
  )
208
211
 
209
212
  @property
@@ -224,7 +224,8 @@ class _FeishuCardActionListener:
224
224
  self.domain = card.domain
225
225
  self.encrypt_key = card.encrypt_key
226
226
  self.verification_token = card.verification_token
227
- self.link = None
227
+ self._links = {}
228
+ self._links_lock = threading.Lock()
228
229
  self._async_thread = _AsyncLoopThread()
229
230
  self._thread = None
230
231
  self._client = None
@@ -250,14 +251,29 @@ class _FeishuCardActionListener:
250
251
  self._thread.start()
251
252
 
252
253
  def register(self, link: PycodexRuntimeLink) -> None:
253
- self.link = link
254
+ message_id = str(getattr(link, "message_id", "") or "").strip()
255
+ if not message_id:
256
+ raise RuntimeError("Feishu linked card message_id is required")
257
+ with self._links_lock:
258
+ self._links[message_id] = link
254
259
 
255
260
  def unregister(self, link: PycodexRuntimeLink) -> None:
256
- if self.link is link:
257
- self.link = None
261
+ message_id = str(getattr(link, "message_id", "") or "").strip()
262
+ with self._links_lock:
263
+ if message_id and self._links.get(message_id) is link:
264
+ del self._links[message_id]
265
+ return
266
+ stale_ids = [
267
+ registered_id
268
+ for registered_id, registered_link in self._links.items()
269
+ if registered_link is link
270
+ ]
271
+ for registered_id in stale_ids:
272
+ del self._links[registered_id]
258
273
 
259
274
  def empty(self) -> bool:
260
- return self.link is None
275
+ with self._links_lock:
276
+ return not self._links
261
277
 
262
278
  def stop(self) -> None:
263
279
  self._stop_requested.set()
@@ -329,16 +345,9 @@ class _FeishuCardActionListener:
329
345
 
330
346
  def _handle_card_action(self, event) -> 'typing.Dict[str, object]':
331
347
  try:
332
- link = self.link
333
- if link is None or link._detached:
334
- return {
335
- "toast": {
336
- "type": "warning",
337
- "content": "pycodex is detached.",
338
- }
339
- }
340
348
  message_id = _event_message_id(event)
341
- if message_id and message_id != link.message_id:
349
+ link = self._resolve_link(message_id)
350
+ if link is None or link._detached:
342
351
  return {
343
352
  "toast": {
344
353
  "type": "warning",
@@ -355,6 +364,15 @@ class _FeishuCardActionListener:
355
364
  }
356
365
  }
357
366
 
367
+ def _resolve_link(self, message_id):
368
+ message_id = str(message_id or "").strip()
369
+ with self._links_lock:
370
+ if message_id:
371
+ return self._links.get(message_id)
372
+ if len(self._links) == 1:
373
+ return next(iter(self._links.values()))
374
+ return None
375
+
358
376
 
359
377
  def _event_message_id(event) -> "typing.Union[str, None]":
360
378
  event_data = getattr(event, "event", None)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-codex"
7
- version = "0.2.2"
7
+ version = "0.2.3"
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"
@@ -45,7 +45,10 @@ default-groups = []
45
45
 
46
46
  [tool.hatch.build.targets.wheel]
47
47
  packages = ["pycodex", "responses_server", "workspace_server"]
48
- artifacts = ["workspace_server/workspace.html"]
48
+ artifacts = [
49
+ "workspace_server/workspace.html",
50
+ "workspace_server/workspaces.html",
51
+ ]
49
52
 
50
53
  [tool.hatch.build.targets.sdist]
51
54
  exclude = [".venv*"]
@@ -975,6 +975,23 @@ def test_get_tools_exec_mode_matches_codex_exec_subset() -> 'None':
975
975
  )
976
976
 
977
977
 
978
+ @pytest.mark.asyncio
979
+ async def test_get_tools_exec_mode_uses_explicit_cwd(tmp_path) -> 'None':
980
+ registry = get_tools(exec_mode=True, cwd=tmp_path)
981
+ tool = registry.get_tool("exec_command")
982
+ assert tool is not None
983
+
984
+ output = await tool.run(
985
+ None,
986
+ {
987
+ "cmd": "pwd",
988
+ "yield_time_ms": 1000,
989
+ },
990
+ )
991
+
992
+ assert str(tmp_path) in str(output)
993
+
994
+
978
995
  def _tool_payload_by_name(registry: 'ToolRegistry') -> 'typing.Dict[str, typing.Dict[str, object]]':
979
996
  payloads: 'typing.Dict[str, typing.Dict[str, object]]' = {}
980
997
  for payload in [spec.serialize() for spec in registry.model_visible_specs()]:
@@ -86,6 +86,18 @@ def test_context_manager_reads_auto_compact_limit_from_codex_config(tmp_path) ->
86
86
  assert manager.resolve_auto_compact_token_limit() == 12345
87
87
 
88
88
 
89
+ def test_context_manager_accepts_explicit_cwd(tmp_path, monkeypatch) -> 'None':
90
+ explicit_cwd = tmp_path / "workspace"
91
+ explicit_cwd.mkdir()
92
+ other_cwd = tmp_path / "other"
93
+ other_cwd.mkdir()
94
+ monkeypatch.chdir(other_cwd)
95
+
96
+ manager = ContextManager(cwd=explicit_cwd)
97
+
98
+ assert manager.cwd == explicit_cwd.resolve()
99
+
100
+
89
101
  @pytest.mark.parametrize(
90
102
  "model",
91
103
  ["step-3.5-flash", "step-3.5-flash-2603", "step-3.7-flash"],
@@ -88,18 +88,34 @@ async def _forever():
88
88
  await asyncio.sleep(3600)
89
89
 
90
90
 
91
- def test_feishu_listener_tracks_one_active_link() -> None:
91
+ def test_feishu_listener_tracks_active_links_by_message_id() -> None:
92
92
  listener = _FeishuCardActionListener(PycodexCard(app_id="app", app_secret="secret"))
93
93
  first = _Object(message_id="om_first")
94
94
  second = _Object(message_id="om_second")
95
95
 
96
96
  listener.register(first)
97
- assert listener.link is first
97
+ assert listener._resolve_link("om_first") is first
98
+ listener.register(second)
99
+ assert listener._resolve_link("om_first") is first
100
+ assert listener._resolve_link("om_second") is second
101
+
98
102
  listener.unregister(first)
99
- assert listener.link is None
103
+ assert listener._resolve_link("om_first") is None
104
+ assert listener._resolve_link("om_second") is second
100
105
 
101
- listener.register(second)
102
- assert listener.link is second
106
+ listener.unregister(second)
107
+ assert listener.empty()
108
+
109
+
110
+ def test_feishu_listener_unregisters_link_if_message_id_changes() -> None:
111
+ listener = _FeishuCardActionListener(PycodexCard(app_id="app", app_secret="secret"))
112
+ link = _Object(message_id="om_first")
113
+
114
+ listener.register(link)
115
+ link.message_id = "om_second"
116
+ listener.unregister(link)
117
+
118
+ assert listener.empty()
103
119
 
104
120
 
105
121
  def test_feishu_listener_stops_when_last_link_is_released(monkeypatch) -> None:
@@ -112,7 +128,29 @@ def test_feishu_listener_stops_when_last_link_is_released(monkeypatch) -> None:
112
128
 
113
129
  _release_feishu_listener(listener, link)
114
130
 
115
- assert listener.link is None
131
+ assert listener.empty()
132
+ assert stopped == [True]
133
+ assert feishu_link._LISTENER is None
134
+
135
+
136
+ def test_feishu_listener_does_not_stop_until_all_links_are_released(monkeypatch) -> None:
137
+ listener = _FeishuCardActionListener(PycodexCard(app_id="app", app_secret="secret"))
138
+ first = _Object(message_id="om_first")
139
+ second = _Object(message_id="om_second")
140
+ stopped = []
141
+ listener.stop = lambda: stopped.append(True)
142
+ listener.register(first)
143
+ listener.register(second)
144
+ monkeypatch.setattr(feishu_link, "_LISTENER", listener)
145
+
146
+ _release_feishu_listener(listener, first)
147
+
148
+ assert stopped == []
149
+ assert feishu_link._LISTENER is listener
150
+ assert listener._resolve_link("om_second") is second
151
+
152
+ _release_feishu_listener(listener, second)
153
+
116
154
  assert stopped == [True]
117
155
  assert feishu_link._LISTENER is None
118
156
 
@@ -131,6 +169,60 @@ def test_feishu_listener_relink_creates_fresh_listener(monkeypatch) -> None:
131
169
  assert feishu_link._LISTENER is second
132
170
 
133
171
 
172
+ def test_feishu_listener_routes_card_action_by_message_id(monkeypatch) -> None:
173
+ listener = _FeishuCardActionListener(PycodexCard(app_id="app", app_secret="secret"))
174
+ first = _Object(
175
+ message_id="om_first",
176
+ _detached=False,
177
+ card=_Object(parse_action=lambda event: {"action": "send", "prompt": "first"}),
178
+ )
179
+ second = _Object(
180
+ message_id="om_second",
181
+ _detached=False,
182
+ card=_Object(parse_action=lambda event: {"action": "send", "prompt": "second"}),
183
+ )
184
+ submitted = []
185
+
186
+ async def submit(action):
187
+ submitted.append(action["prompt"])
188
+ return {"toast": {"type": "info", "content": action["prompt"]}}
189
+
190
+ first._submit = submit
191
+ second._submit = submit
192
+
193
+ def run_coro(coro):
194
+ loop = asyncio.new_event_loop()
195
+ try:
196
+ return loop.run_until_complete(coro)
197
+ finally:
198
+ loop.close()
199
+
200
+ listener._async_thread.run = run_coro
201
+
202
+ listener.register(first)
203
+ listener.register(second)
204
+
205
+ result = listener._handle_card_action(_event("om_second"))
206
+
207
+ assert submitted == ["second"]
208
+ assert result["toast"]["content"] == "second"
209
+
210
+
211
+ def test_feishu_listener_rejects_unknown_card_action_message_id() -> None:
212
+ listener = _FeishuCardActionListener(PycodexCard(app_id="app", app_secret="secret"))
213
+ listener.register(_Object(message_id="om_first", _detached=False))
214
+ listener.register(_Object(message_id="om_second", _detached=False))
215
+
216
+ result = listener._handle_card_action(_event("om_stale"))
217
+
218
+ assert result == {
219
+ "toast": {
220
+ "type": "warning",
221
+ "content": "pycodex is detached.",
222
+ }
223
+ }
224
+
225
+
134
226
  def test_feishu_listener_replaces_running_sdk_module_loop(monkeypatch) -> None:
135
227
  client_module = types.ModuleType("lark_oapi.ws.client")
136
228
  started_loops = []