python-codex 0.4.6__py3-none-any.whl → 0.4.8__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.
pycodex/agent.py CHANGED
@@ -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))
pycodex/bootstrap.py CHANGED
@@ -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(
pycodex/context.py CHANGED
@@ -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
pycodex/runtime.py CHANGED
@@ -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: "
@@ -163,6 +163,7 @@ class SessionRolloutRecorder:
163
163
  def list_resumable_sessions(
164
164
  codex_home: "Path",
165
165
  limit: "int" = 20,
166
+ session_id: "typing.Union[str, None]" = None,
166
167
  ) -> "typing.Tuple[typing.Dict[str, str], ...]":
167
168
  latest_rollouts_by_id: "typing.Dict[str, Path]" = {}
168
169
  for dirname in ROLLUP_SESSION_DIRNAMES:
@@ -173,6 +174,8 @@ def list_resumable_sessions(
173
174
  thread_id = _thread_id_from_rollout_path(path)
174
175
  if thread_id is None:
175
176
  continue
177
+ if session_id is not None and thread_id.lower() != session_id.lower():
178
+ continue
176
179
  previous = latest_rollouts_by_id.get(thread_id)
177
180
  if previous is None or path.stat().st_mtime > previous.stat().st_mtime:
178
181
  latest_rollouts_by_id[thread_id] = path
@@ -202,11 +205,16 @@ def list_resumable_sessions(
202
205
 
203
206
  def select_resumable_session(
204
207
  codex_home: "Path",
205
- resume_index_text: "str",
208
+ resume_target: "str",
206
209
  ) -> "typing.Dict[str, str]":
207
- normalized_target = resume_index_text.strip()
210
+ normalized_target = resume_target.strip()
211
+ if UUID_PATTERN.fullmatch(normalized_target):
212
+ sessions = list_resumable_sessions(codex_home, 1, normalized_target)
213
+ if not sessions:
214
+ raise ValueError(f"Session not found: {normalized_target}")
215
+ return sessions[0]
208
216
  if not normalized_target.isdigit():
209
- raise ValueError("Usage: /resume <number>")
217
+ raise ValueError("Usage: /resume <number|session_id>")
210
218
 
211
219
  sessions = list_resumable_sessions(codex_home)
212
220
  resume_index = int(normalized_target)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-codex
3
- Version: 0.4.6
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,17 +378,22 @@ 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`.
388
391
  - `/resume` with no argument lists the currently resumable sessions by their
389
392
  first user-message preview; `/resume 1` resumes the first listed session
390
- - `/resume <number>` replaces the in-memory history with the selected recorded
391
- Codex rollout from `CODEX_HOME/sessions`. Tool calls without recorded results
393
+ - `/resume <session_id>` accepts a full session UUID, including sessions older
394
+ than the displayed list. Both forms search `CODEX_HOME/sessions` and
395
+ `CODEX_HOME/archived_sessions` and replace the in-memory history with the
396
+ selected recorded Codex rollout. Tool calls without recorded results
392
397
  are omitted during restore; later saved messages and completed tools remain
393
398
  available, and the source file is left intact.
394
399
  - `/compact` synthesizes a local handoff summary, replaces the in-memory
@@ -452,6 +457,11 @@ Current behavior:
452
457
  original state paths and are saved in the new format. See the
453
458
  [workspace configuration example](packages/pycodex-ws/README.md).
454
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.
455
465
  Board HTML can reference local
456
466
  images beside the board (including nested paths) with relative URLs; only
457
467
  `image/*` files contained by the board directory are served.
@@ -811,6 +821,9 @@ event JSON format.
811
821
  uses `agent.tool_registry.runtime_environment`, not a forwarding Agent property.
812
822
  The global `get_agent_runtime_environment()` and
813
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.
814
827
  - Each sub-agent gets its own context manager and runtime services. The sub-agent
815
828
  service retains terminal event status and queues one notification per terminal
816
829
  event. The parent consumes these notifications before its next normal model
@@ -1,13 +1,13 @@
1
1
  pycodex/__init__.py,sha256=fHEyhylULeRo-5xEnl-UxT5ZpBzbFzRW7vLwdy_7oxU,2660
2
2
  pycodex/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
3
- pycodex/agent.py,sha256=GF-mNTnxHW8qw-72QEXfGGvExZzl8c3m6emZBux6ddw,21418
4
- pycodex/bootstrap.py,sha256=U1X5kwORpS5JnPk9HhIiOVWPQzDLlk_ro_cY2knwL8I,12421
3
+ pycodex/agent.py,sha256=4YtHLDL0lKpU_KWnbKTNyI5UEfBPgkI4f8lndn8yjGA,21807
4
+ pycodex/bootstrap.py,sha256=wUNipqvIPzJlcP3PTY0cCyPH_QTeIbSR8xtyw4AEP6Y,12282
5
5
  pycodex/cli.py,sha256=uCrRARI2IypIU-1aC0Y2DLzhzYGWQta1f7zYWsyaNnU,12797
6
- pycodex/context.py,sha256=yfNY2alT3caQQwhay47pSSrGUbgEAcLKhTVaZqtQ4yU,26610
6
+ pycodex/context.py,sha256=8P3fBXbwzTKmoXGDdkzTsAghfOkAZstJnMTqr-cxHyI,27019
7
7
  pycodex/events.py,sha256=L7RkZ3EtFQOfWg1FIG2Y0mLpDKTKV6HGmXMy9HCoWX0,29794
8
8
  pycodex/model.py,sha256=VwPN9GEdoL8N8V4hFdMmJ1rGxL_tom3Nq7ZBonCum2k,45648
9
9
  pycodex/protocol.py,sha256=uzUz_06iA4m9vAoqZeCGiJ4SocKZDzOfCJUU4Bpt5dI,12033
10
- pycodex/runtime.py,sha256=M_gvWDvsZBivELjNkuhmwiZVRmlaQmzKZK1ZQ5-kLLE,24271
10
+ pycodex/runtime.py,sha256=kEW4dLGcTX0nRArDpWhwm62c1il_1O-AgRHvsQqSYBY,24661
11
11
  pycodex/portable/__init__.py,sha256=FmspRPmblYuZIVXMpXaxE1uosCaz2hZDpkpOzxDJQic,1084
12
12
  pycodex/portable/client.py,sha256=DOxzZljVfN1ynmhqrChX7NsT1IFSFVKzEn1J3wvV3XQ,16038
13
13
  pycodex/portable/server.py,sha256=w4YD71Sxb7MD5q57GPuIgx7AoicTv0R9xzX2kAaibPA,7503
@@ -23,15 +23,15 @@ pycodex/prompts/permissions/sandbox_mode/read_only.md,sha256=2rAPEXsBYCcuttI5j3e
23
23
  pycodex/prompts/permissions/sandbox_mode/workspace_write.md,sha256=lVN-LwrBbHqlv5yVjcd_mU8tzZW8jfKpTatJKIZu9HI,277
24
24
  pycodex/tools/__init__.py,sha256=UYwmsX_Ckw_YaYQ6TYhUU6w5pxi2qzZoTrq6BAgNErM,1857
25
25
  pycodex/tools/agent_tool_schemas.py,sha256=8cL9ml4So0H6IIeK0ZzjAo9bk3VeprDOSYPpIamOqEU,2048
26
- pycodex/tools/apply_patch_tool.py,sha256=t8XFCGZ7q-s9C1Ty6qkReBRk-QD3ONvcKXLAz9djKRY,13956
27
- pycodex/tools/base_tool.py,sha256=Oqe-0RgH3mz70YckgBFD-tdvNJ9TQXrb0tuMbdy3bfc,6477
26
+ pycodex/tools/apply_patch_tool.py,sha256=n30M-ofZlOkDCC18nX10MPF5FLROiURACFLG1hd4FK8,13906
27
+ pycodex/tools/base_tool.py,sha256=NtzDTCudWIdwjhfgk6kd98C5KVl3PrruymOOURLApSA,6655
28
28
  pycodex/tools/clock_tool.py,sha256=2_RYbBEw4V9_f1L-qZl3hhJ6XNPM_gBDmOlCh1cCAjo,6575
29
29
  pycodex/tools/close_agent_tool.py,sha256=uIbCjsQx9MLkjVYheS8ZVf5VVPbC6Iviwox4pneJpmI,2030
30
- pycodex/tools/code_mode_manager.py,sha256=HUgtXxj3hGj2i4_tsToHLr8mGTV8uIob9vtYAZeLnQc,19166
31
- pycodex/tools/exec_command_tool.py,sha256=iqRiKaaX-AjG6LjATAMOakHIP0gpCWCj2o8af_8eAiQ,4464
30
+ pycodex/tools/code_mode_manager.py,sha256=oEzYkebFdgX993NBC0A4R0mthg4qdhJ-dD0fgeE3III,18948
31
+ pycodex/tools/exec_command_tool.py,sha256=EvlV355-i9trrKTAXDFiZyYMviTxL_FehhKlQmjuDpw,4443
32
32
  pycodex/tools/exec_runtime.js,sha256=DR1uocKailTqNWAcJNFJuQgFFMSUzTpT_uQsRaneg2s,3643
33
33
  pycodex/tools/exec_tool.py,sha256=tuC21rlhP8CPSYml4XnSDSRV0UPGkQ6aHj0DLIoOOvM,2274
34
- pycodex/tools/grep_files_tool.py,sha256=Ysw68VKI9OEvUH5haYx8FWuthkHykc1wskbuzjj8gTk,5293
34
+ pycodex/tools/grep_files_tool.py,sha256=KCBVNaSTU45ND-iAhh14YawMyjQnMRZnGI1NGKL3zgg,4809
35
35
  pycodex/tools/list_dir_tool.py,sha256=_U6ouRcVbGHT5mChVyUsiMgDGrFK7EFMFaspPLAHtH8,5337
36
36
  pycodex/tools/read_file_tool.py,sha256=YK__uhIVldBjAQega5S_ZuZU5jgP_kLOwr-YhjAmRo4,9735
37
37
  pycodex/tools/request_permissions_tool.py,sha256=nEt3_uw3lmFXyydqcN3PxuKwrEvaRPtfjA_U8nikCtU,3978
@@ -39,12 +39,12 @@ pycodex/tools/request_user_input_tool.py,sha256=_7siHF2p3KiNDFwsTqKKIUw2kPCXMzLa
39
39
  pycodex/tools/resume_agent_tool.py,sha256=bZvXEUNdc1RVD2N5Msjv63Rni62FQk4gj9LAXK1Vsy8,1613
40
40
  pycodex/tools/runtime_services.py,sha256=KPLtB1e7jmqEN9rhXV70rUMlhYNFsXja4CypIunzXLU,15962
41
41
  pycodex/tools/send_input_tool.py,sha256=w63SKM05_e3oXvdaBtL-hk941TvkWKuTUDEqTSyWEMQ,3761
42
- pycodex/tools/shell_command_tool.py,sha256=US63I5xiV5zfO1Oox2yHUg7F6XoKzmyxtohfaQIsmrw,4216
43
- pycodex/tools/shell_tool.py,sha256=lqFH2XosfBg7yQKhpB7i4vLGz9sNQtEZihD0rjsmBCQ,4068
42
+ pycodex/tools/shell_command_tool.py,sha256=bJKlTdIs2i28vH5f4GZfpunezr6JNicRzsL_6Vc3HvU,3675
43
+ pycodex/tools/shell_tool.py,sha256=I6lYLABSmLddRrNmpk0Li83AV55MI8x0bckWaRkFGI0,3527
44
44
  pycodex/tools/spawn_agent_tool.py,sha256=Jx5jYpxsH_CQ3BLkXYPZuIINW5deO_zY8tyw8kZpreY,6208
45
- pycodex/tools/unified_exec_manager.py,sha256=XPN6PgBnjZ5WFZATNIqpWKUAuw8HMMD7mlfHJQjJQZM,14544
45
+ pycodex/tools/unified_exec_manager.py,sha256=n6N9Sf125Tt4Vdl8C0lqng8tQbS7Dj04uZnDVqgGkxw,14056
46
46
  pycodex/tools/update_plan_tool.py,sha256=xwrSrerw5qfkbFc9Ltx1OZEmD__CWtryOy3UhL0-Jp4,3198
47
- pycodex/tools/view_image_tool.py,sha256=SCoiIHaQ51oYjYX69MRxjAK9b1P6_wOCIuZqzsC2rj8,4082
47
+ pycodex/tools/view_image_tool.py,sha256=H7OP-QZEU5Gf-E4YnQbXfu2hDkL5-dJ3Apla2YYjErQ,3754
48
48
  pycodex/tools/wait_agent_tool.py,sha256=pLsnk9TxSt4v7krC7s7vxi91pLisSNU0lATt9s-P_3E,3134
49
49
  pycodex/tools/wait_tool.py,sha256=0_OO_DvMsBhG3oDKZePTOjZTBq1lmWntXAzqIqvebdA,3206
50
50
  pycodex/tools/web_search_tool.py,sha256=3ao1IvJw7MwcP35UKLbqrM-nlm5Z-UUFVdEzkJYOV1o,988
@@ -57,12 +57,12 @@ pycodex/utils/crypto.py,sha256=HRCXF26Ft3S8CG2X0YpZwa8GMU9YJ0Loo8Q9PMPMcNw,1249
57
57
  pycodex/utils/debug.py,sha256=5XMpWpwFSm6woyx6RGXvN2qL0afRtTQbwlOAv-LotTw,913
58
58
  pycodex/utils/doctor.py,sha256=fI66oQqB5zNL7aEpFbiHnzhELyp9XydwMyAGznw5YSE,10616
59
59
  pycodex/utils/dotenv.py,sha256=h7ZgvqpEswetqjhtwfiaHyokAHwNKPakizTAHPHz5Ds,1865
60
- pycodex/utils/event_helpers.py,sha256=EJToMDBcoxSYN71IUyZu_OEb0T0yXU4InYrowYon-OQ,6442
60
+ pycodex/utils/event_helpers.py,sha256=ar4WoIkpiShoayN_GZlnDijy74CC2Z8_5p76EryduNk,6517
61
61
  pycodex/utils/get_env.py,sha256=L4S8h84tkgxZKvrxY7ezTgePbXepG-yIigUTKeY6uuE,6094
62
62
  pycodex/utils/image_utils.py,sha256=OUzUs0Cow4L8Z2np0EBeEUotAqqsCmRmhrV8AxZUXRw,2460
63
63
  pycodex/utils/model_metadata.py,sha256=uK3iI4yC7Ge7yN4I1_gQYalQ4MYKddz2qMwsmQb-P80,1337
64
64
  pycodex/utils/random_ids.py,sha256=jLTxZZ0MBbRdQOFDFWGVRjWzIo6FKSQ5ADXbQSLPp1s,379
65
- pycodex/utils/session_persist.py,sha256=_7Lz1jO7D0Qf16UVuZBKU_Si-k_cUVinnLMlsd1jVj4,23600
65
+ pycodex/utils/session_persist.py,sha256=P-tsYdb1r0Ca_mVgTcK24bZM09zR-tNFgoFXlVIn5Q0,24012
66
66
  pycodex/utils/truncation.py,sha256=5D6vAorT8EgXgjd7ASwtraAyIZdlCR-w5jKZqJxDzho,5446
67
67
  pycodex/utils/_aes_gcm/LICENSE,sha256=KvdHagY3sYIK34GldJaRi34sNnGwMVJIIoDhbC9MRu8,2805
68
68
  pycodex/utils/_aes_gcm/README.md,sha256=D9XGeewCgU2PVqJCO6WusUs0Mw2FbMnABCqaVktqCnE,3045
@@ -83,8 +83,8 @@ responses_server/trajectory_dump.py,sha256=5_Wy6tslszrqx3j1JeP_boyTbzIicKy8No84P
83
83
  responses_server/tools/__init__.py,sha256=ivsBSEy0SBUhY-Uea5v1XMLXShkwHdCVl0id-1FwdZg,150
84
84
  responses_server/tools/custom_adapter.py,sha256=SnIiNUdomff3APtTriURAC9YCeEdhCDjzGMEYusfUdk,8340
85
85
  responses_server/tools/web_search.py,sha256=pnKH9JyTScRehWpZS3HpXRvjWsQvC0QZqxjEOCT4ndg,8936
86
- python_codex-0.4.6.dist-info/METADATA,sha256=uC7ydz7Y3S65w0jugBRDWho_eH7-zTfD_wWjt93qG90,47911
87
- python_codex-0.4.6.dist-info/WHEEL,sha256=KGYbc1zXlYddvwxnNty23BeaKzh7YuoSIvIMO4jEhvw,87
88
- python_codex-0.4.6.dist-info/entry_points.txt,sha256=sNUVakoVuTrzJH505ZgRTQxmtRRPUHV_EH0i6EbYTyM,45
89
- python_codex-0.4.6.dist-info/licenses/LICENSE,sha256=0X8ifk312hYAORM4hlzg8wVSEXYKNmiPgWlB1YIy2Nw,10926
90
- python_codex-0.4.6.dist-info/RECORD,,
86
+ python_codex-0.4.8.dist-info/METADATA,sha256=OXHvFKPrz7ORC-G8Z-2VkiAzoiSFAyzgvE5ifGVO67E,48951
87
+ python_codex-0.4.8.dist-info/WHEEL,sha256=KGYbc1zXlYddvwxnNty23BeaKzh7YuoSIvIMO4jEhvw,87
88
+ python_codex-0.4.8.dist-info/entry_points.txt,sha256=sNUVakoVuTrzJH505ZgRTQxmtRRPUHV_EH0i6EbYTyM,45
89
+ python_codex-0.4.8.dist-info/licenses/LICENSE,sha256=0X8ifk312hYAORM4hlzg8wVSEXYKNmiPgWlB1YIy2Nw,10926
90
+ python_codex-0.4.8.dist-info/RECORD,,