parallel-codex 0.1.2__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.
@@ -0,0 +1,24 @@
1
+ """Parallel Codex Python toolkit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .core import (
6
+ WorktreePlan,
7
+ ensure_worktree,
8
+ find_worktree,
9
+ format_plan,
10
+ list_worktrees,
11
+ plan_worktree,
12
+ prune_worktree,
13
+ )
14
+
15
+ __all__ = [
16
+ "WorktreePlan",
17
+ "plan_worktree",
18
+ "ensure_worktree",
19
+ "list_worktrees",
20
+ "find_worktree",
21
+ "prune_worktree",
22
+ "format_plan",
23
+ ]
24
+ __version__ = "0.1.1"
parallel_codex/cli.py ADDED
@@ -0,0 +1,49 @@
1
+ """Command-line interface entry point for the Parallel Codex toolkit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Callable
8
+
9
+ from . import __version__
10
+ from .commands import register as register_commands
11
+
12
+
13
+ def build_parser() -> argparse.ArgumentParser:
14
+ """Create the top-level CLI parser."""
15
+
16
+ parser = argparse.ArgumentParser(
17
+ prog="parallel-codex",
18
+ description="Utilities for orchestrating Parallel Codex agents",
19
+ )
20
+ parser.add_argument(
21
+ "--version",
22
+ action="version",
23
+ version=f"parallel-codex {__version__}",
24
+ )
25
+
26
+ subparsers = parser.add_subparsers(dest="command", required=True)
27
+ register_commands(subparsers)
28
+ return parser
29
+
30
+
31
+ def main(argv: list[str] | None = None) -> int:
32
+ """Return an exit code after executing the requested subcommand."""
33
+
34
+ parser = build_parser()
35
+ args = parser.parse_args(argv)
36
+ handler: Callable[..., int] | None = getattr(args, "handler", None)
37
+ if handler is None:
38
+ parser.error("No handler registered for command.")
39
+ return handler(args)
40
+
41
+
42
+ def run(argv: list[str] | None = None) -> None:
43
+ """Execute the CLI and exit the current process."""
44
+
45
+ sys.exit(main(argv))
46
+
47
+
48
+ if __name__ == "__main__": # pragma: no cover
49
+ run()
@@ -0,0 +1,27 @@
1
+ """Command modules for the Parallel Codex CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from argparse import ArgumentParser, _SubParsersAction
6
+ from pathlib import Path
7
+
8
+
9
+ def add_common_arguments(parser: ArgumentParser) -> None:
10
+ """Attach arguments shared by multiple subcommands."""
11
+
12
+ parser.add_argument(
13
+ "--base-dir",
14
+ type=Path,
15
+ default=Path("./.agents"),
16
+ help="Directory where agent worktrees are stored (default: ./.agents)",
17
+ )
18
+
19
+
20
+ def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
21
+ """Import command modules and register their subparsers."""
22
+
23
+ from . import list_worktrees, plan, prune
24
+
25
+ plan.register(subparsers)
26
+ list_worktrees.register(subparsers)
27
+ prune.register(subparsers)
@@ -0,0 +1,35 @@
1
+ """`parallel-codex list` implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from argparse import ArgumentParser, Namespace, _SubParsersAction
6
+
7
+ from ..core import format_plan, list_worktrees
8
+ from . import add_common_arguments
9
+
10
+
11
+ def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
12
+ parser = subparsers.add_parser(
13
+ "list",
14
+ help="List discovered agent worktrees",
15
+ )
16
+ add_common_arguments(parser)
17
+ parser.add_argument(
18
+ "--agent",
19
+ help="Filter results to a specific agent name",
20
+ )
21
+ parser.set_defaults(handler=execute)
22
+
23
+
24
+ def execute(args: Namespace) -> int:
25
+ plans = list_worktrees(args.base_dir)
26
+ if args.agent:
27
+ plans = [plan for plan in plans if plan.name == args.agent]
28
+
29
+ if not plans:
30
+ print("No worktrees found.")
31
+ return 0
32
+
33
+ for plan in plans:
34
+ print(format_plan(plan))
35
+ return 0
@@ -0,0 +1,32 @@
1
+ """`parallel-codex plan` implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from argparse import ArgumentParser, Namespace, _SubParsersAction
6
+
7
+ from ..core import ensure_worktree, format_plan, plan_worktree
8
+ from . import add_common_arguments
9
+
10
+
11
+ def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
12
+ parser = subparsers.add_parser(
13
+ "plan",
14
+ help="Create or update the plan for a Codex agent worktree",
15
+ )
16
+ add_common_arguments(parser)
17
+ parser.add_argument("agent", help="Agent identifier to plan")
18
+ parser.add_argument("branch", help="Git branch the agent should track")
19
+ parser.add_argument(
20
+ "--ensure",
21
+ action="store_true",
22
+ help="Materialise the plan by creating the worktree folder and metadata",
23
+ )
24
+ parser.set_defaults(handler=execute)
25
+
26
+
27
+ def execute(args: Namespace) -> int:
28
+ plan = plan_worktree(args.base_dir, args.agent, args.branch)
29
+ if args.ensure:
30
+ ensure_worktree(plan)
31
+ print(format_plan(plan))
32
+ return 0
@@ -0,0 +1,34 @@
1
+ """`parallel-codex prune` implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from argparse import ArgumentParser, Namespace, _SubParsersAction
6
+
7
+ from ..core import find_worktree, prune_worktree
8
+ from . import add_common_arguments
9
+
10
+
11
+ def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
12
+ parser = subparsers.add_parser(
13
+ "prune",
14
+ help="Remove plan metadata (and optionally the worktree directory)",
15
+ )
16
+ add_common_arguments(parser)
17
+ parser.add_argument("agent", help="Agent identifier to prune")
18
+ parser.add_argument(
19
+ "--prune-dir",
20
+ action="store_true",
21
+ help="Delete the worktree directory in addition to metadata",
22
+ )
23
+ parser.set_defaults(handler=execute)
24
+
25
+
26
+ def execute(args: Namespace) -> int:
27
+ plan = find_worktree(args.base_dir, args.agent)
28
+ if plan is None:
29
+ print(f"No plan found for agent '{args.agent}'.")
30
+ return 1
31
+
32
+ prune_worktree(plan, remove_path=args.prune_dir)
33
+ print(f"Pruned plan for agent '{args.agent}'.")
34
+ return 0
parallel_codex/core.py ADDED
@@ -0,0 +1,95 @@
1
+ """Core functionality for orchestrating Parallel Codex agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ BRANCH_METADATA = ".parallel-codex-branch"
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class WorktreePlan:
14
+ """Describes the desired state for an agent worktree."""
15
+
16
+ name: str
17
+ branch: str
18
+ path: Path
19
+
20
+
21
+ def plan_worktree(base_dir: Path, agent_name: str, branch: str) -> WorktreePlan:
22
+ """Create a worktree plan rooted within ``base_dir``.
23
+
24
+ Args:
25
+ base_dir: Root directory containing agent worktrees.
26
+ agent_name: Identifier for the Codex agent.
27
+ branch: Target git branch for the worktree.
28
+
29
+ Returns:
30
+ A :class:`WorktreePlan` describing the desired worktree placement.
31
+ """
32
+
33
+ worktree_path = base_dir / agent_name
34
+ return WorktreePlan(name=agent_name, branch=branch, path=worktree_path)
35
+
36
+
37
+ def format_plan(plan: WorktreePlan) -> str:
38
+ """Render a human-friendly summary of a worktree plan."""
39
+
40
+ return f"agent={plan.name} branch={plan.branch} path={plan.path}"
41
+
42
+
43
+ def ensure_worktree(plan: WorktreePlan) -> None:
44
+ """Materialise metadata for a planned worktree."""
45
+
46
+ plan.path.mkdir(parents=True, exist_ok=True)
47
+ (plan.path / BRANCH_METADATA).write_text(f"{plan.branch}\n", encoding="utf-8")
48
+
49
+
50
+ def list_worktrees(base_dir: Path) -> list[WorktreePlan]:
51
+ """Enumerate worktree plans stored under ``base_dir``."""
52
+
53
+ if not base_dir.exists():
54
+ return []
55
+
56
+ plans: list[WorktreePlan] = []
57
+ for candidate in base_dir.iterdir():
58
+ if not candidate.is_dir():
59
+ continue
60
+
61
+ branch = _load_branch(candidate)
62
+ if branch is None:
63
+ continue
64
+ plans.append(WorktreePlan(name=candidate.name, branch=branch, path=candidate))
65
+
66
+ return sorted(plans, key=lambda plan: plan.name)
67
+
68
+
69
+ def find_worktree(base_dir: Path, agent_name: str) -> WorktreePlan | None:
70
+ """Return the stored worktree plan for ``agent_name`` if present."""
71
+
72
+ candidate = base_dir / agent_name
73
+ branch = _load_branch(candidate)
74
+ if branch is None:
75
+ return None
76
+ return WorktreePlan(name=agent_name, branch=branch, path=candidate)
77
+
78
+
79
+ def prune_worktree(plan: WorktreePlan, *, remove_path: bool = False) -> None:
80
+ """Remove stored metadata (and optionally the directory) for a plan."""
81
+
82
+ branch_file = plan.path / BRANCH_METADATA
83
+ if branch_file.exists():
84
+ branch_file.unlink()
85
+
86
+ if remove_path and plan.path.exists():
87
+ shutil.rmtree(plan.path)
88
+
89
+
90
+ def _load_branch(path: Path) -> str | None:
91
+ branch_file = path / BRANCH_METADATA
92
+ if not branch_file.exists():
93
+ return None
94
+ raw = branch_file.read_text(encoding="utf-8").strip()
95
+ return raw or None
@@ -0,0 +1,419 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ pcodex - ultra-minimal cross-platform CLI to manage agent worktrees + tmux sessions.
4
+
5
+ Commands:
6
+ up <agent> <branch> Ensure git worktree and tmux session;
7
+ optionally run `codex .` and attach/switch.
8
+ switch <agent> Switch/attach to tmux session.
9
+ list List worktrees and tmux session status.
10
+ prune <agent> Optionally kill tmux session and/or remove the worktree directory.
11
+
12
+ Requires: git, tmux (or WSL with tmux installed).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import os
18
+ import shlex
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+ import threading
23
+ import time
24
+ from collections.abc import Iterable
25
+ from pathlib import Path
26
+
27
+ BRANCH_METADATA = ".parallel-codex-branch"
28
+ DEFAULT_BASE_DIR = Path("./.agents")
29
+ SESSION_PREFIX = "pcx-"
30
+
31
+
32
+ def _supports_ansi() -> bool:
33
+ if os.environ.get("NO_COLOR") or os.environ.get("PCX_NO_COLOR"):
34
+ return False
35
+ try:
36
+ return sys.stdout.isatty()
37
+ except Exception:
38
+ return False
39
+
40
+
41
+ ANSI = {
42
+ "reset": "\x1b[0m",
43
+ "green": "\x1b[32m",
44
+ "red": "\x1b[31m",
45
+ "cyan": "\x1b[36m",
46
+ "dim": "\x1b[2m",
47
+ }
48
+ USE_ANSI = _supports_ansi()
49
+
50
+
51
+ def _c(code: str, text: str) -> str:
52
+ if not USE_ANSI:
53
+ return text
54
+ return f"{ANSI.get(code, '')}{text}{ANSI['reset']}"
55
+
56
+
57
+ class Spinner:
58
+ def __init__(self, message: str, done_text: str | None = None):
59
+ self.message = message
60
+ self.done_text = done_text or message
61
+ self._stop = threading.Event()
62
+ self._thread: threading.Thread | None = None
63
+ self._frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
64
+
65
+ def __enter__(self):
66
+ if not sys.stdout.isatty():
67
+ print(f"{self.message} …", flush=True)
68
+ return self
69
+ self._thread = threading.Thread(target=self._spin, daemon=True)
70
+ self._thread.start()
71
+ return self
72
+
73
+ def _spin(self):
74
+ i = 0
75
+ while not self._stop.is_set():
76
+ frame = self._frames[i % len(self._frames)]
77
+ line = f"{_c('cyan', frame)} {_c('dim', self.message)}"
78
+ print(f"\r{line}", end="", flush=True)
79
+ time.sleep(0.08)
80
+ i += 1
81
+
82
+ def __exit__(self, exc_type, exc, tb):
83
+ self._stop.set()
84
+ if self._thread:
85
+ self._thread.join(timeout=0.2)
86
+ if sys.stdout.isatty():
87
+ print("\r", end="")
88
+ if exc is None:
89
+ print(f"{_c('green', '✔')} {self.done_text}")
90
+ else:
91
+ print(f"{_c('red', '✖')} {self.done_text}")
92
+ return False
93
+
94
+
95
+ def run(
96
+ cmd: Iterable[str],
97
+ *,
98
+ check: bool = True,
99
+ capture: bool = False,
100
+ env: dict[str, str] | None = None,
101
+ cwd: Path | None = None,
102
+ ) -> subprocess.CompletedProcess:
103
+ kwargs = {
104
+ "check": check,
105
+ "cwd": str(cwd) if cwd else None,
106
+ "env": env or os.environ.copy(),
107
+ "text": True,
108
+ }
109
+ if capture:
110
+ kwargs.update({"stdout": subprocess.PIPE, "stderr": subprocess.PIPE})
111
+ return subprocess.run(list(cmd), **kwargs) # type: ignore[arg-type]
112
+
113
+
114
+ def which(name: str) -> str | None:
115
+ return shutil.which(name)
116
+
117
+
118
+ class Tmux:
119
+ def __init__(self, prefix: list[str], path_mapper):
120
+ self.prefix = prefix
121
+ self.path_mapper = path_mapper
122
+
123
+ def _cmd(self, *args: str) -> list[str]:
124
+ return [*self.prefix, *args]
125
+
126
+ def has_session(self, name: str) -> bool:
127
+ proc = run(self._cmd("has-session", "-t", name), check=False, capture=True)
128
+ return proc.returncode == 0
129
+
130
+ def new_session(self, name: str, cwd: Path) -> None:
131
+ run(self._cmd("new-session", "-ds", name, "-c", self.path_mapper(cwd)))
132
+
133
+ def send_keys(self, name: str, command: str) -> None:
134
+ run(self._cmd("send-keys", "-t", name, command, "C-m"))
135
+
136
+ def switch_or_attach(self, name: str) -> None:
137
+ inside_tmux = bool(os.environ.get("TMUX"))
138
+ if inside_tmux:
139
+ run(self._cmd("switch-client", "-t", name), check=False)
140
+ else:
141
+ run(self._cmd("attach", "-t", name), check=False)
142
+
143
+ def kill_session(self, name: str) -> None:
144
+ run(self._cmd("kill-session", "-t", name), check=False)
145
+
146
+
147
+ def _windows_wsl_path(path: Path) -> str:
148
+ wsl = which("wsl.exe")
149
+ if wsl:
150
+ try:
151
+ proc = run([wsl, "wslpath", "-a", str(path)], capture=True)
152
+ return proc.stdout.strip()
153
+ except Exception:
154
+ pass
155
+ p = str(path)
156
+ if len(p) >= 2 and p[1] == ":":
157
+ drive = p[0].lower()
158
+ rest = p[2:].replace("\\", "/")
159
+ return f"/mnt/{drive}{rest if rest.startswith('/') else '/' + rest}"
160
+ return p.replace("\\", "/")
161
+
162
+
163
+ def tmux_strategy(*, prefer_wsl: bool = False) -> Tmux:
164
+ is_windows = os.name == "nt"
165
+ native_tmux = which("tmux")
166
+ wsl = which("wsl.exe")
167
+ if prefer_wsl and is_windows and wsl:
168
+ return Tmux(prefix=[wsl, "--", "tmux"], path_mapper=_windows_wsl_path)
169
+ if native_tmux:
170
+ return Tmux(prefix=[native_tmux], path_mapper=lambda p: str(p))
171
+ if is_windows:
172
+ if wsl:
173
+ return Tmux(prefix=[wsl, "--", "tmux"], path_mapper=_windows_wsl_path)
174
+ return Tmux(prefix=["tmux"], path_mapper=lambda p: str(p))
175
+
176
+
177
+ def ensure_worktree(repo: Path, base_dir: Path, agent: str, branch: str) -> Path:
178
+ base_dir.mkdir(parents=True, exist_ok=True)
179
+ worktree = base_dir / agent
180
+ if not worktree.exists():
181
+ worktree.parent.mkdir(parents=True, exist_ok=True)
182
+ try:
183
+ run(["git", "-C", str(repo), "worktree", "add", "-B", branch, str(worktree)], check=True)
184
+ except subprocess.CalledProcessError:
185
+ pass
186
+ try:
187
+ (worktree / BRANCH_METADATA).write_text(f"{branch}\n", encoding="utf-8")
188
+ except Exception:
189
+ pass
190
+ return worktree
191
+
192
+
193
+ def read_branch_file(worktree: Path) -> str | None:
194
+ meta = worktree / BRANCH_METADATA
195
+ if meta.exists():
196
+ raw = meta.read_text(encoding="utf-8").strip()
197
+ return raw or None
198
+ return None
199
+
200
+
201
+ def cmd_up(args: argparse.Namespace) -> int:
202
+ tmux = tmux_strategy(prefer_wsl=bool(getattr(args, "wsl", False)))
203
+ session = SESSION_PREFIX + args.agent
204
+ repo = Path(args.repo).resolve()
205
+ base_dir = Path(args.base_dir).resolve()
206
+
207
+ with Spinner(
208
+ f"Ensuring worktree for agent '{args.agent}' on '{args.branch}'",
209
+ "Worktree ensured",
210
+ ):
211
+ worktree = ensure_worktree(repo, base_dir, args.agent, args.branch)
212
+
213
+ # If we're about to create the session for the first time, try to prepare
214
+ # the Python environment so jumping into the session is ready to go.
215
+ needs_session = not tmux.has_session(session)
216
+ if needs_session and bool(getattr(args, "prep_env", False)):
217
+ # Detect if tmux will run under WSL so we prep the env in the same OS.
218
+ is_wsl_tmux = bool(
219
+ tmux.prefix
220
+ and os.path.basename(tmux.prefix[0]).lower().startswith("wsl")
221
+ )
222
+ if is_wsl_tmux:
223
+ wsl = tmux.prefix[0]
224
+ wsl_worktree = _windows_wsl_path(worktree)
225
+ # Check if uv exists inside WSL
226
+ uv_present = (
227
+ run(
228
+ [wsl, "--", "bash", "-lc", "command -v uv >/dev/null 2>&1"],
229
+ check=False,
230
+ ).returncode
231
+ == 0
232
+ )
233
+ if uv_present:
234
+ with Spinner(
235
+ "Preparing Python env in WSL (uv sync + install -e)",
236
+ "Python env ready",
237
+ ):
238
+ commands = [
239
+ f"cd {shlex.quote(wsl_worktree)}",
240
+ "uv sync --project packages/python-package",
241
+ (
242
+ "uv run --project packages/python-package python -m pip install -e "
243
+ "packages/python-package"
244
+ ),
245
+ ]
246
+ cmd = " && ".join(commands)
247
+ run([wsl, "--", "bash", "-lc", cmd], check=False)
248
+ else:
249
+ print(_c("dim", "Tip: 'uv' not found in WSL; skipping dependency sync/install."))
250
+ else:
251
+ uv = which("uv")
252
+ if uv:
253
+ with Spinner(
254
+ "Preparing Python env (uv sync + install -e)",
255
+ "Python env ready",
256
+ ):
257
+ # Best-effort; do not fail the whole command if these error
258
+ run(
259
+ [uv, "sync", "--project", "packages/python-package"],
260
+ check=False,
261
+ cwd=worktree,
262
+ )
263
+ run(
264
+ [
265
+ uv,
266
+ "run",
267
+ "--project",
268
+ "packages/python-package",
269
+ "python",
270
+ "-m",
271
+ "pip",
272
+ "install",
273
+ "-e",
274
+ "packages/python-package",
275
+ ],
276
+ check=False,
277
+ cwd=worktree,
278
+ )
279
+ else:
280
+ print(_c("dim", "Tip: 'uv' not found on PATH; skipping dependency sync/install."))
281
+
282
+ with Spinner(f"Ensuring tmux session '{session}'", "Tmux session ready"):
283
+ if needs_session:
284
+ tmux.new_session(session, worktree)
285
+ if args.run_codex:
286
+ tmux.send_keys(session, "codex .")
287
+
288
+ if args.attach:
289
+ tmux.switch_or_attach(session)
290
+ else:
291
+ print(f"{_c('dim', 'Tip:')} run with --attach to switch/attach to the session.")
292
+ return 0
293
+
294
+
295
+ def cmd_switch(args: argparse.Namespace) -> int:
296
+ tmux = tmux_strategy(prefer_wsl=bool(getattr(args, "wsl", False)))
297
+ session = SESSION_PREFIX + args.agent
298
+ if not tmux.has_session(session):
299
+ print(_c("red", f"Session '{session}' not found."), file=sys.stderr)
300
+ return 1
301
+ tmux.switch_or_attach(session)
302
+ return 0
303
+
304
+
305
+ def cmd_list(args: argparse.Namespace) -> int:
306
+ tmux = tmux_strategy(prefer_wsl=bool(getattr(args, "wsl", False)))
307
+ base_dir = Path(args.base_dir).resolve()
308
+ if not base_dir.exists():
309
+ print("No worktrees found.")
310
+ return 0
311
+ any_printed = False
312
+ for child in sorted(base_dir.iterdir(), key=lambda p: p.name):
313
+ if not child.is_dir():
314
+ continue
315
+ agent = child.name
316
+ session = SESSION_PREFIX + agent
317
+ branch = read_branch_file(child) or "?"
318
+ tmux_up = "up" if tmux.has_session(session) else "down"
319
+ print(f"agent={agent} branch={branch} path={child} tmux={tmux_up}")
320
+ any_printed = True
321
+ if not any_printed:
322
+ print("No worktrees found.")
323
+ return 0
324
+
325
+
326
+ def cmd_prune(args: argparse.Namespace) -> int:
327
+ tmux = tmux_strategy(prefer_wsl=bool(getattr(args, "wsl", False)))
328
+ base_dir = Path(args.base_dir).resolve()
329
+ worktree = base_dir / args.agent
330
+ session = SESSION_PREFIX + args.agent
331
+ if args.kill_session:
332
+ with Spinner(f"Killing tmux session '{session}'", "Tmux session handled"):
333
+ tmux.kill_session(session)
334
+ if args.remove_dir and worktree.exists():
335
+ with Spinner(f"Removing worktree directory '{worktree}'", "Worktree directory handled"):
336
+ shutil.rmtree(worktree, ignore_errors=True)
337
+ print(f"Pruned '{args.agent}'.")
338
+ return 0
339
+
340
+
341
+ def build_parser() -> argparse.ArgumentParser:
342
+ p = argparse.ArgumentParser(prog="pcodex", description="Parallel Codex single-file CLI")
343
+ p.add_argument(
344
+ "--base-dir",
345
+ default=str(DEFAULT_BASE_DIR),
346
+ help="Directory for agent worktrees (default: ./.agents)",
347
+ )
348
+ sub = p.add_subparsers(dest="command", required=True)
349
+
350
+ up = sub.add_parser("up", help="Ensure worktree + tmux; optionally run codex and attach")
351
+ up.add_argument("agent")
352
+ up.add_argument("branch")
353
+ up.add_argument("--repo", default=".", help="Path to git repo (default: .)")
354
+ up.add_argument(
355
+ "--attach",
356
+ action="store_true",
357
+ help="Switch/attach to the tmux session after setup",
358
+ )
359
+ up.add_argument("--run-codex", action="store_true", help="Send 'codex .' into the tmux session")
360
+ up.add_argument(
361
+ "--prep-env",
362
+ action="store_true",
363
+ help="Before creating the session, run 'uv sync' and install the package in editable mode",
364
+ )
365
+ up.add_argument(
366
+ "--wsl",
367
+ action="store_true",
368
+ help="Force tmux to run via WSL on Windows and run commands inside WSL",
369
+ )
370
+ up.set_defaults(handler=cmd_up)
371
+
372
+ sw = sub.add_parser("switch", help="Switch/attach to an existing tmux session")
373
+ sw.add_argument("agent")
374
+ sw.add_argument(
375
+ "--wsl",
376
+ action="store_true",
377
+ help="Use WSL tmux session (Windows)",
378
+ )
379
+ sw.set_defaults(handler=cmd_switch)
380
+
381
+ ls = sub.add_parser("list", help="List known agent worktrees and tmux state")
382
+ ls.add_argument(
383
+ "--wsl",
384
+ action="store_true",
385
+ help="Use WSL tmux instance to query session state (Windows)",
386
+ )
387
+ ls.set_defaults(handler=cmd_list)
388
+
389
+ pr = sub.add_parser("prune", help="Kill tmux and/or remove a worktree directory")
390
+ pr.add_argument("agent")
391
+ pr.add_argument(
392
+ "--kill-session",
393
+ action="store_true",
394
+ help="Kill the tmux session for the agent",
395
+ )
396
+ pr.add_argument("--remove-dir", action="store_true", help="Delete the agent worktree directory")
397
+ pr.add_argument(
398
+ "--wsl",
399
+ action="store_true",
400
+ help="Target a WSL tmux session (Windows)",
401
+ )
402
+ pr.set_defaults(handler=cmd_prune)
403
+
404
+ return p
405
+
406
+
407
+ def main(argv: list[str] | None = None) -> int:
408
+ parser = build_parser()
409
+ args = parser.parse_args(argv)
410
+ handler = getattr(args, "handler", None)
411
+ if handler is None:
412
+ parser.error("No handler.")
413
+ return handler(args)
414
+
415
+
416
+ if __name__ == "__main__":
417
+ sys.exit(main())
418
+
419
+
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: parallel-codex
3
+ Version: 0.1.2
4
+ Summary: Python toolkit for orchestrating Parallel Codex agents across isolated worktrees.
5
+ Project-URL: Homepage, https://github.com/parallel-codex/parallel-codex
6
+ Project-URL: Issues, https://github.com/parallel-codex/parallel-codex/issues
7
+ Project-URL: Repository, https://github.com/parallel-codex/parallel-codex.git
8
+ Project-URL: Documentation, https://github.com/parallel-codex/parallel-codex/tree/main/packages/python-package
9
+ Author-email: Parallel Codex Team <alejandro.quiros3101@gmail.com>
10
+ License: MIT
11
+ Keywords: agents,automation,codex,git,worktree
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Requires-Python: >=3.11
20
+ Provides-Extra: dev
21
+ Requires-Dist: mypy>=1.7.0; extra == 'dev'
22
+ Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
23
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
24
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # parallel-codex
28
+
29
+ Python helpers for orchestrating Parallel Codex agents working in isolated Git worktrees. The toolkit offers a typed core module and a small CLI for planning agent worktrees that the TypeScript layer can execute.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ uv tool install parallel-codex
35
+ # or: pip install parallel-codex
36
+ ```
37
+
38
+ You’ll get two CLIs:
39
+ - `pcodex` – minimal, cross‑platform helper that manages git worktrees + tmux and can run `codex .`
40
+ - `parallel-codex` – lower-level planner/list/prune for worktree metadata
41
+
42
+ Prerequisites:
43
+ - `git` and `tmux` on PATH. On Windows without tmux, `pcodex` auto-falls back to `wsl.exe -- tmux ...`.
44
+ - The `codex` command should be available if you use `--run-codex`.
45
+
46
+ ## Commands
47
+
48
+ - `uv sync` – install dependencies defined in `pyproject.toml`
49
+ - `uv build` – build a wheel and sdist using Hatchling
50
+ - `uv run pytest` – execute the test suite
51
+ - `uv run ruff check .` – lint the codebase
52
+ - `uv run mypy src/` – run type checking with MyPy
53
+
54
+ ## Release Checklist
55
+
56
+ 1. Update the `version` field in `pyproject.toml`.
57
+ 2. Commit and push the changes.
58
+ 3. Tag the commit with `py-vX.Y.Z` (or `vX.Y.Z`) and push the tag to trigger the GitHub Actions publish workflow.
59
+ 4. Confirm the new release appears on [PyPI](https://pypi.org/project/parallel-codex/).
60
+
61
+ ## CLI Usage (quickstart)
62
+
63
+ ```bash
64
+ pcodex up reviewer main --run-codex --attach
65
+ pcodex switch reviewer
66
+ pcodex list
67
+ pcodex prune reviewer --kill-session --remove-dir
68
+ ```
69
+
70
+ ## CLI Usage (development, no install)
71
+
72
+ Run the CLIs without installing the package:
73
+
74
+ ```bash
75
+ uv run src/main.py plan reviewer main --base-dir ./.agents
76
+ # or the single-file helper:
77
+ uv run src/parallel_codex/pcodex.py up reviewer main --run-codex --attach
78
+ ```
79
+
80
+ The published CLIs expose sub-commands:
81
+
82
+ - `parallel-codex plan <agent> <branch>` – calculate (and optionally materialise) a worktree plan.
83
+ - `parallel-codex list` – list discovered plans inside a base directory.
84
+ - `parallel-codex prune <agent>` – remove stored metadata, with `--prune-dir` to delete the folder entirely.
85
+ - `pcodex up <agent> <branch>` – ensure git worktree, ensure tmux session, optionally run `codex .`, and attach.
86
+ - `pcodex switch <agent>` – switch/attach to the tmux session.
87
+ - `pcodex list` – list worktrees and tmux session state.
88
+ - `pcodex prune <agent> [--kill-session] [--remove-dir]` – kill session and/or remove directory.
89
+
90
+ Each sub-command accepts `--base-dir` to target a custom location (defaults to `./.agents`).
91
+
92
+ ## Library Usage
93
+
94
+ Import the helpers in automation scripts:
95
+
96
+ ```python
97
+ from pathlib import Path
98
+ from parallel_codex import plan_worktree
99
+
100
+ plan = plan_worktree(Path("./agents"), "summariser", "feature/summary")
101
+ print(plan.path)
102
+ ```
103
+
104
+ Or rely on the CLI for quick experiments:
105
+
106
+ ```bash
107
+ uv run parallel-codex summariser feature/summary --base-dir ./agents
108
+ ```
109
+
110
+ The CLI prints a single line summary describing the worktree location, agent name, and branch target.
@@ -0,0 +1,12 @@
1
+ parallel_codex/__init__.py,sha256=ikj-qgJy4bfSqHAtRW1UuDJDoFrjax-Jk2SBtNlhIXY,415
2
+ parallel_codex/cli.py,sha256=-7lhX0WRZaXNLYz_oO_lRQCl7la1_WoIEXQiYqgsx60,1301
3
+ parallel_codex/core.py,sha256=amaBbChhjRtASSEOMEtjmXJj9gDO3p-sn8Yd4KHVSUI,2758
4
+ parallel_codex/pcodex.py,sha256=F6-XoSTKnC6r-JI_msehQLD4gDYDPCynG2lDpdVONIc,14011
5
+ parallel_codex/commands/__init__.py,sha256=tzgxCijCc3CJKLAAxTtdG1T3QgJIkJgnn66Vu_8ji8o,758
6
+ parallel_codex/commands/list_worktrees.py,sha256=H9Jh63cpgEqBof9QK6T2OdbKJC06oHyvNHNKxBS6TEU,898
7
+ parallel_codex/commands/plan.py,sha256=vvIaSRB7TllSyaQZ8hJ4RbaB0iLgfbksFF8vAIugP2s,1018
8
+ parallel_codex/commands/prune.py,sha256=1J4LAPS2uQzqSmI7PpFT70WvdEmsYMQ10FW15tqtmFs,1040
9
+ parallel_codex-0.1.2.dist-info/METADATA,sha256=6WhRqySbFR3l9OyTnxhHrEUrHWUb2ll8ocXS2MgWmew,4171
10
+ parallel_codex-0.1.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
+ parallel_codex-0.1.2.dist-info/entry_points.txt,sha256=VVRMF6OB7HeIFLWNWfUPkwK9cA5dkJm2Ss6MxIQgT8s,95
12
+ parallel_codex-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ parallel-codex = parallel_codex.cli:main
3
+ pcodex = parallel_codex.pcodex:main