parallel-agents-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pa.py ADDED
@@ -0,0 +1,761 @@
1
+ #!/usr/bin/env python3
2
+ """pa - keep parallel coding agents out of each other's way.
3
+
4
+ Before an agent edits files, it claims them. Other agents see the claim and
5
+ work elsewhere instead of overwriting each other. Claims expire on their own,
6
+ so a crashed agent never blocks anyone for long.
7
+
8
+ State lives in <git common dir>/parallel-agents (shared by every worktree of
9
+ the repo and never committed), or in .parallel-agents/ outside git.
10
+
11
+ Zero dependencies. Python 3.9+. macOS, Linux and Windows.
12
+ """
13
+
14
+ import argparse
15
+ import contextlib
16
+ import fnmatch
17
+ import json
18
+ import os
19
+ import re
20
+ import subprocess
21
+ import sys
22
+ import tempfile
23
+ import time
24
+ import uuid
25
+
26
+ try:
27
+ import fcntl # POSIX
28
+ except ImportError: # pragma: no cover - Windows
29
+ fcntl = None
30
+ import msvcrt
31
+
32
+ VERSION = "0.1.0"
33
+ STATE_NAME = "parallel-agents"
34
+ DEFAULT_TTL = 30 * 60
35
+ EXIT_OK, EXIT_ERROR, EXIT_USAGE, EXIT_CONFLICT = 0, 1, 2, 3
36
+ GLOB_CHARS = "*?["
37
+ NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
38
+ WINDOWS_RESERVED = {"con", "prn", "aux", "nul"} | {"com%d" % i for i in range(1, 10)} | {"lpt%d" % i for i in range(1, 10)}
39
+ CASE_INSENSITIVE_FS = sys.platform in ("darwin", "win32")
40
+
41
+
42
+ class UsageError(Exception):
43
+ pass
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Locating the repository and its shared state
48
+ # ---------------------------------------------------------------------------
49
+
50
+ class Workspace:
51
+ """Where we are: the worktree root (paths are relative to it) and the
52
+ state directory shared by all agents."""
53
+
54
+ def __init__(self, root, state):
55
+ self.root = os.path.realpath(root)
56
+ self.state = state
57
+
58
+ @classmethod
59
+ def discover(cls, cwd=None):
60
+ cwd = os.path.realpath(cwd or os.getcwd())
61
+ override = os.environ.get("PA_DIR")
62
+ git, error = _git_dirs(cwd)
63
+ if git:
64
+ root, common = git
65
+ return cls(root, override or os.path.join(common, STATE_NAME))
66
+ # Never fall back to a private board inside a repo that git can't read:
67
+ # that agent would see no one else's claims.
68
+ probe = cwd
69
+ while True:
70
+ if os.path.exists(os.path.join(probe, ".git")):
71
+ raise UsageError("this is a git repository but git failed (%s); fix git first" % (error or "unknown error"))
72
+ parent = os.path.dirname(probe)
73
+ if parent == probe:
74
+ break
75
+ probe = parent
76
+ # Outside git: use the nearest .parallel-agents up the tree, or create one here.
77
+ probe = cwd
78
+ while True:
79
+ candidate = os.path.join(probe, "." + STATE_NAME)
80
+ if os.path.isdir(candidate):
81
+ return cls(probe, override or candidate)
82
+ parent = os.path.dirname(probe)
83
+ if parent == probe:
84
+ return cls(cwd, override or os.path.join(cwd, "." + STATE_NAME))
85
+ probe = parent
86
+
87
+ @property
88
+ def claims_dir(self):
89
+ return os.path.join(self.state, "claims")
90
+
91
+ @property
92
+ def log_path(self):
93
+ return os.path.join(self.state, "log.jsonl")
94
+
95
+ def ensure(self):
96
+ os.makedirs(self.claims_dir, exist_ok=True)
97
+
98
+
99
+ def _git_dirs(cwd):
100
+ """((worktree root, common dir), None) or (None, error text)."""
101
+ try:
102
+ out = subprocess.run(
103
+ ["git", "rev-parse", "--show-toplevel", "--git-common-dir"],
104
+ cwd=cwd, capture_output=True, text=True, timeout=10,
105
+ )
106
+ except (OSError, subprocess.SubprocessError) as e:
107
+ return None, str(e)
108
+ if out.returncode != 0:
109
+ return None, out.stderr.strip().splitlines()[-1] if out.stderr.strip() else "exit %d" % out.returncode
110
+ lines = out.stdout.strip().splitlines()
111
+ if len(lines) < 2:
112
+ return None, "unexpected git output"
113
+ root, common = lines[0], lines[1]
114
+ if not os.path.isabs(common):
115
+ common = os.path.join(cwd, common)
116
+ return (os.path.realpath(root), os.path.realpath(common)), None
117
+
118
+
119
+ def same_path(a, b):
120
+ if a is None or b is None:
121
+ return a == b
122
+ a, b = os.path.normpath(a), os.path.normpath(b)
123
+ return a.casefold() == b.casefold() if CASE_INSENSITIVE_FS else a == b
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Locking and storage
128
+ # ---------------------------------------------------------------------------
129
+
130
+ @contextlib.contextmanager
131
+ def locked(ws):
132
+ """Exclusive lock around every read and write of the board, so two agents
133
+ can never claim the same file at the same moment."""
134
+ ws.ensure()
135
+ fd = os.open(os.path.join(ws.state, ".lock"), os.O_RDWR | os.O_CREAT, 0o644)
136
+ try:
137
+ if fcntl:
138
+ fcntl.flock(fd, fcntl.LOCK_EX)
139
+ else: # pragma: no cover - Windows
140
+ deadline = time.time() + 30
141
+ while True:
142
+ try:
143
+ os.lseek(fd, 0, 0)
144
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
145
+ break
146
+ except OSError:
147
+ if time.time() > deadline:
148
+ raise OSError("could not lock %s after 30s" % ws.state)
149
+ time.sleep(0.02)
150
+ yield
151
+ finally:
152
+ if fcntl:
153
+ fcntl.flock(fd, fcntl.LOCK_UN)
154
+ else: # pragma: no cover - Windows
155
+ try:
156
+ os.lseek(fd, 0, 0)
157
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
158
+ except OSError:
159
+ pass
160
+ os.close(fd)
161
+
162
+
163
+ def _atomic_write(path, data):
164
+ directory = os.path.dirname(path)
165
+ fd, tmp = tempfile.mkstemp(dir=directory, prefix=".tmp-")
166
+ try:
167
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
168
+ json.dump(data, f, indent=2, sort_keys=True)
169
+ f.flush()
170
+ os.fsync(f.fileno())
171
+ os.replace(tmp, path)
172
+ except BaseException:
173
+ with contextlib.suppress(OSError):
174
+ os.unlink(tmp)
175
+ raise
176
+
177
+
178
+ def _agent_file(ws, agent):
179
+ return os.path.join(ws.claims_dir, agent + ".json")
180
+
181
+
182
+ def _valid_claim(c):
183
+ return (isinstance(c, dict) and isinstance(c.get("paths"), list) and c["paths"]
184
+ and all(isinstance(p, str) for p in c["paths"])
185
+ and isinstance(c.get("heartbeat"), (int, float)) and isinstance(c.get("ttl"), (int, float))
186
+ and isinstance(c.get("created"), (int, float)))
187
+
188
+
189
+ def load_all(ws):
190
+ """{agent: [claim, ...]} for every agent with a claims file. Call under the lock."""
191
+ result = {}
192
+ if not os.path.isdir(ws.claims_dir):
193
+ return result
194
+ for name in sorted(os.listdir(ws.claims_dir)):
195
+ if not name.endswith(".json") or name.startswith("."):
196
+ continue
197
+ try:
198
+ with open(os.path.join(ws.claims_dir, name), encoding="utf-8") as f:
199
+ data = json.load(f)
200
+ except (OSError, ValueError):
201
+ continue # a corrupt file never takes the whole board down
202
+ if not isinstance(data, dict) or not isinstance(data.get("claims"), list):
203
+ continue
204
+ agent = str(data.get("agent") or name[:-5]).lower()
205
+ result[agent] = [c for c in data["claims"] if _valid_claim(c)]
206
+ return result
207
+
208
+
209
+ def save_agent(ws, agent, claims):
210
+ path = _agent_file(ws, agent)
211
+ if claims:
212
+ _atomic_write(path, {"agent": agent, "claims": claims})
213
+ else:
214
+ with contextlib.suppress(FileNotFoundError):
215
+ os.unlink(path)
216
+
217
+
218
+ def is_active(claim, now):
219
+ return now - claim["heartbeat"] <= claim["ttl"]
220
+
221
+
222
+ def drop_expired(ws, board, now):
223
+ """Remove expired claims from disk. Returns the cleaned board and how many went."""
224
+ removed = 0
225
+ for agent in list(board):
226
+ alive = [c for c in board[agent] if is_active(c, now)]
227
+ if len(alive) != len(board[agent]):
228
+ removed += len(board[agent]) - len(alive)
229
+ save_agent(ws, agent, alive)
230
+ board[agent] = alive
231
+ if not board[agent]:
232
+ del board[agent]
233
+ return board, removed
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # Paths, resources and overlap
238
+ # ---------------------------------------------------------------------------
239
+
240
+ def _is_glob(p):
241
+ return any(ch in p for ch in GLOB_CHARS)
242
+
243
+
244
+ def normalize(ws, raw, cwd=None):
245
+ """Turn a user path into a repo-relative POSIX path.
246
+
247
+ - '@name' is a named resource (migrations, deps, a port) and is kept as is.
248
+ - An existing directory gets a trailing '/' (for display; any path covers
249
+ everything below it, existing or not).
250
+ - Globs are kept as patterns relative to the repo root, with the folder
251
+ part before the first wildcard resolved like any other path.
252
+ """
253
+ raw = raw.strip()
254
+ if not raw:
255
+ raise UsageError("empty path")
256
+ if raw.startswith("@"):
257
+ name = raw[1:].strip().lower()
258
+ if not name:
259
+ raise UsageError("empty resource name after '@'")
260
+ return "@" + name
261
+ cwd = os.path.realpath(cwd or os.getcwd())
262
+ unified = raw.replace("\\", "/") if os.sep == "\\" else raw
263
+ if _is_glob(unified):
264
+ parts = unified.split("/")
265
+ first = next(i for i, part in enumerate(parts) if _is_glob(part))
266
+ static = "/".join(parts[:first]) or "."
267
+ tail = "/".join(parts[first:]).rstrip("/")
268
+ base = os.path.realpath(os.path.join(cwd, static))
269
+ absolute = os.path.normpath(os.path.join(base, tail))
270
+ else:
271
+ absolute = os.path.realpath(os.path.join(cwd, raw))
272
+ try:
273
+ rel = os.path.relpath(absolute, ws.root)
274
+ except ValueError: # another drive on Windows
275
+ raise UsageError("%s is outside the repository (%s)" % (raw, ws.root))
276
+ if rel == os.curdir:
277
+ raise UsageError("refusing to claim the whole repository; claim the files or folders you will edit")
278
+ if rel == os.pardir or rel.startswith(os.pardir + os.sep):
279
+ raise UsageError("%s is outside the repository (%s)" % (raw, ws.root))
280
+ rel = rel.replace(os.sep, "/").rstrip("/")
281
+ if not _is_glob(rel) and os.path.isdir(os.path.join(ws.root, rel)):
282
+ rel += "/"
283
+ return rel
284
+
285
+
286
+ def _static_prefix(pattern):
287
+ """'src/api/*.py' -> 'src/api'; '*.py' -> '' (could match anything)."""
288
+ cut = min((pattern.index(ch) for ch in GLOB_CHARS if ch in pattern), default=len(pattern))
289
+ head = pattern[:cut]
290
+ return head[: head.rfind("/")] if "/" in head else ""
291
+
292
+
293
+ def _variants(glob):
294
+ """'**/' may also match zero folders: 'src/**/*.py' also means 'src/*.py'."""
295
+ seen, todo = set(), [glob]
296
+ while todo:
297
+ g = todo.pop()
298
+ if g in seen:
299
+ continue
300
+ seen.add(g)
301
+ i = g.find("**/")
302
+ while i != -1:
303
+ todo.append(g[:i] + g[i + 3:])
304
+ i = g.find("**/", i + 1)
305
+ return seen
306
+
307
+
308
+ def _matches(path, glob):
309
+ return any(fnmatch.fnmatchcase(path, g) for g in _variants(glob))
310
+
311
+
312
+ def _witness(segment):
313
+ """A concrete file name the last glob segment would match."""
314
+ s = re.sub(r"\[![^\]]*\]", "~", segment)
315
+ s = re.sub(r"\[([^\]])[^\]]*\]", r"\1", s)
316
+ s = s.replace("*", "x").replace("?", "x")
317
+ return s or "x"
318
+
319
+
320
+ def _under(path, folder):
321
+ return path == folder or path.startswith(folder + "/")
322
+
323
+
324
+ def _glob_touches(glob, concrete):
325
+ """Could a glob claim and a concrete claim (which covers itself and
326
+ everything below it) touch the same file? Errs on the side of yes."""
327
+ prefix = _static_prefix(glob)
328
+ if prefix and _under(prefix, concrete): # the concrete folder contains the glob's whole area
329
+ return True
330
+ parts = concrete.split("/")
331
+ for i in range(len(parts), 0, -1): # the concrete path, or a folder above it, is matched
332
+ if _matches("/".join(parts[:i]), glob):
333
+ return True
334
+ leaf = _witness(glob.rsplit("/", 1)[-1])
335
+ for depth in range(5): # something below the concrete folder is matched
336
+ if _matches(concrete + "/" + "x/" * depth + leaf, glob):
337
+ return True
338
+ return False
339
+
340
+
341
+ def overlaps(a, b):
342
+ """Could these two claims touch the same file? Errs on the side of yes:
343
+ every comparison is case-insensitive (macOS and Windows file systems are),
344
+ and every path also covers anything that might later appear below it."""
345
+ a, b = a.casefold().rstrip("/"), b.casefold().rstrip("/")
346
+ if a.startswith("@") or b.startswith("@"):
347
+ return a == b
348
+ ga, gb = _is_glob(a), _is_glob(b)
349
+ if not ga and not gb:
350
+ return _under(a, b) or _under(b, a)
351
+ if ga and gb:
352
+ pa, pb = _static_prefix(a), _static_prefix(b)
353
+ return pa == "" or pb == "" or _under(pa, pb) or _under(pb, pa)
354
+ return _glob_touches(a, b) if ga else _glob_touches(b, a)
355
+
356
+
357
+ def same_claim_path(a, b):
358
+ return a.casefold().rstrip("/") == b.casefold().rstrip("/")
359
+
360
+
361
+ def covers(held, wanted):
362
+ """Does a path I already hold also cover this one? A folder covers what is
363
+ under it, so claiming a file inside a folder of mine records nothing new.
364
+ Keeps the board readable when an agent claims a folder and then edits
365
+ thirty files in it."""
366
+ h, w = held.casefold().rstrip("/"), wanted.casefold().rstrip("/")
367
+ if h == w:
368
+ return True
369
+ if h.startswith("@") or w.startswith("@") or _is_glob(w):
370
+ return False
371
+ return _matches(w, h) if _is_glob(h) else _under(w, h)
372
+
373
+
374
+ def find_overlaps(ws, board, agent, paths, now):
375
+ """Split other agents' overlapping claims into blocking (same worktree)
376
+ and heads-up warnings (another worktree of the same repo)."""
377
+ conflicts, warnings = [], []
378
+ for other, claims in board.items():
379
+ if other == agent:
380
+ continue
381
+ for claim in claims:
382
+ if not is_active(claim, now):
383
+ continue
384
+ hits = sorted({p for p in paths for q in claim["paths"] if overlaps(p, q)})
385
+ if not hits:
386
+ continue
387
+ entry = {"agent": other, "claim": claim, "requested": hits}
388
+ (conflicts if same_path(claim.get("worktree"), ws.root) else warnings).append(entry)
389
+ return conflicts, warnings
390
+
391
+
392
+ # ---------------------------------------------------------------------------
393
+ # Formatting
394
+ # ---------------------------------------------------------------------------
395
+
396
+ def parse_ttl(text):
397
+ match = re.fullmatch(r"\s*(\d+)\s*([smh]?)\s*", text or "")
398
+ if not match:
399
+ raise UsageError("bad --ttl %r (use e.g. 90s, 30m, 2h)" % text)
400
+ value, unit = int(match.group(1)), match.group(2) or "m"
401
+ seconds = value * {"s": 1, "m": 60, "h": 3600}[unit]
402
+ if not 10 <= seconds <= 24 * 3600:
403
+ raise UsageError("--ttl must be between 10s and 24h")
404
+ return seconds
405
+
406
+
407
+ def ago(seconds):
408
+ seconds = max(0, int(seconds))
409
+ if seconds < 60:
410
+ return "%ds" % seconds
411
+ if seconds < 3600:
412
+ return "%dm" % (seconds // 60)
413
+ return "%dh%02dm" % (seconds // 3600, (seconds % 3600) // 60)
414
+
415
+
416
+ def describe(entry, now, ws):
417
+ c = entry["claim"]
418
+ where = "" if same_path(c.get("worktree"), ws.root) else " [worktree %s]" % c.get("worktree")
419
+ why = (' "%s"' % c["why"]) if c.get("why") else ""
420
+ return "%s holds %s%s (since %s ago, expires in %s)%s" % (
421
+ entry["agent"], ", ".join(c["paths"]), why,
422
+ ago(now - c["created"]), ago(c["heartbeat"] + c["ttl"] - now), where)
423
+
424
+
425
+ def brief(entry):
426
+ c = entry["claim"]
427
+ return {"agent": entry["agent"], "paths": c["paths"], "why": c.get("why", ""),
428
+ "requested": entry["requested"], "worktree": c.get("worktree")}
429
+
430
+
431
+ def emit_json(obj):
432
+ print(json.dumps(obj, indent=2, sort_keys=True))
433
+
434
+
435
+ # ---------------------------------------------------------------------------
436
+ # Commands
437
+ # ---------------------------------------------------------------------------
438
+
439
+ def need_agent(args):
440
+ agent = (args.as_ or os.environ.get("PA_AS") or "").strip().lower()
441
+ if not agent:
442
+ raise UsageError("say who you are: --as <name> (e.g. --as claude-7f2c), or set PA_AS")
443
+ if not NAME_RE.match(agent):
444
+ raise UsageError("agent name %r: use letters, digits, '.', '_' or '-' (max 64)" % agent)
445
+ if agent.split(".")[0] in WINDOWS_RESERVED:
446
+ raise UsageError("agent name %r is reserved on Windows; pick another" % agent)
447
+ return agent
448
+
449
+
450
+ def optional_agent(args):
451
+ return need_agent(args) if (args.as_ or os.environ.get("PA_AS")) else None
452
+
453
+
454
+ def cmd_init(ws, args):
455
+ ws.ensure()
456
+ if args.json:
457
+ emit_json({"ok": True, "state": ws.state, "root": ws.root})
458
+ else:
459
+ print("parallel-agents state: %s" % ws.state)
460
+ print("repository root: %s" % ws.root)
461
+ return EXIT_OK
462
+
463
+
464
+ def cmd_claim(ws, args):
465
+ agent = need_agent(args)
466
+ ttl = parse_ttl(args.ttl) if args.ttl else DEFAULT_TTL
467
+ paths = sorted({normalize(ws, p) for p in args.paths})
468
+ now = time.time()
469
+ with locked(ws):
470
+ board, _ = drop_expired(ws, load_all(ws), now)
471
+ mine = board.get(agent, [])
472
+ for c in mine: # any claim attempt, even a blocked one, refreshes all of mine
473
+ c["heartbeat"] = now
474
+ conflicts, warnings = find_overlaps(ws, board, agent, paths, now)
475
+ if conflicts:
476
+ save_agent(ws, agent, mine)
477
+ if args.json:
478
+ emit_json({"ok": False, "agent": agent, "conflicts": [brief(c) for c in conflicts]})
479
+ else:
480
+ print("BLOCKED: nothing was claimed.")
481
+ for c in conflicts:
482
+ print(" " + describe(c, now, ws))
483
+ print("Do not edit these paths. Work on something else and retry later, or ask the user.")
484
+ return EXIT_CONFLICT
485
+
486
+ held = [p for c in mine for p in c["paths"]]
487
+ new_paths = [p for p in paths if not any(covers(h, p) for h in held)]
488
+ for c in mine:
489
+ c["ttl"] = max(c["ttl"], ttl)
490
+ if new_paths:
491
+ mine.append({
492
+ "id": uuid.uuid4().hex[:8],
493
+ "paths": new_paths,
494
+ "why": args.why or "",
495
+ "worktree": ws.root,
496
+ "created": now,
497
+ "heartbeat": now,
498
+ "ttl": ttl,
499
+ })
500
+ save_agent(ws, agent, mine)
501
+
502
+ if args.json:
503
+ emit_json({"ok": True, "agent": agent, "claimed": paths, "expires_in": ttl,
504
+ "warnings": [brief(w) for w in warnings]})
505
+ else:
506
+ print("OK: %s now holds %s (expires in %s unless renewed)." % (agent, ", ".join(paths), ago(ttl)))
507
+ for w in warnings:
508
+ print("WARN: same paths in another worktree, expect a merge conflict: " + describe(w, now, ws))
509
+ return EXIT_OK
510
+
511
+
512
+ def cmd_release(ws, args):
513
+ agent = need_agent(args)
514
+ targets = [normalize(ws, p) for p in args.paths] if args.paths else None
515
+ released, keep = [], []
516
+ with locked(ws):
517
+ mine = load_all(ws).get(agent, [])
518
+ for c in mine:
519
+ if targets is None:
520
+ released.extend(c["paths"])
521
+ continue
522
+ gone = [p for p in c["paths"] if any(same_claim_path(p, t) for t in targets)]
523
+ released.extend(gone)
524
+ remaining = [p for p in c["paths"] if p not in gone]
525
+ if remaining:
526
+ c["paths"] = remaining
527
+ keep.append(c)
528
+ save_agent(ws, agent, keep)
529
+ held = [p for c in keep for p in c["paths"]]
530
+ missing = [t for t in (targets or []) if not any(same_claim_path(t, r) for r in released)]
531
+ if args.json:
532
+ emit_json({"ok": True, "agent": agent, "released": sorted(released), "not_held": missing})
533
+ return EXIT_OK
534
+ if released:
535
+ print("OK: %s released %s." % (agent, ", ".join(sorted(released))))
536
+ elif targets is None:
537
+ print("Nothing to release for %s." % agent)
538
+ for t in missing:
539
+ print("NOT HELD: %s. You hold: %s" % (t, ", ".join(held) or "nothing"))
540
+ return EXIT_OK
541
+
542
+
543
+ def cmd_check(ws, args):
544
+ agent = optional_agent(args)
545
+ paths = sorted({normalize(ws, p) for p in args.paths})
546
+ now = time.time()
547
+ with locked(ws):
548
+ board = load_all(ws)
549
+ conflicts, warnings = find_overlaps(ws, board, agent, paths, now)
550
+ if args.json:
551
+ emit_json({"ok": not conflicts, "free": not conflicts,
552
+ "conflicts": [brief(c) for c in conflicts], "warnings": [brief(w) for w in warnings]})
553
+ elif conflicts:
554
+ for c in conflicts:
555
+ print("TAKEN: " + describe(c, now, ws))
556
+ else:
557
+ print("FREE: %s" % ", ".join(paths))
558
+ for w in warnings:
559
+ print("WARN: " + describe(w, now, ws))
560
+ return EXIT_CONFLICT if conflicts else EXIT_OK
561
+
562
+
563
+ def cmd_status(ws, args):
564
+ now = time.time()
565
+ with locked(ws):
566
+ board = load_all(ws)
567
+ board = {a: [c for c in cs if is_active(c, now)] for a, cs in board.items()}
568
+ board = {a: cs for a, cs in board.items() if cs}
569
+ if args.json:
570
+ emit_json({"ok": True, "state": ws.state, "agents": {
571
+ a: [dict(c, expires_in=int(c["heartbeat"] + c["ttl"] - now)) for c in cs]
572
+ for a, cs in board.items()}})
573
+ return EXIT_OK
574
+ if not board:
575
+ print("No active claims. Everything is free.")
576
+ return EXIT_OK
577
+ for agent, claims in sorted(board.items()):
578
+ print(agent)
579
+ for c in claims:
580
+ where = "" if same_path(c.get("worktree"), ws.root) else " [worktree %s]" % c.get("worktree")
581
+ why = (' "%s"' % c["why"]) if c.get("why") else ""
582
+ print(" %s%s (since %s ago, expires in %s)%s" % (
583
+ ", ".join(c["paths"]), why, ago(now - c["created"]),
584
+ ago(c["heartbeat"] + c["ttl"] - now), where))
585
+ return EXIT_OK
586
+
587
+
588
+ def cmd_renew(ws, args):
589
+ agent = need_agent(args)
590
+ ttl = parse_ttl(args.ttl) if args.ttl else None
591
+ now = time.time()
592
+ with locked(ws):
593
+ board, _ = drop_expired(ws, load_all(ws), now)
594
+ mine = board.get(agent, [])
595
+ for c in mine:
596
+ c["heartbeat"] = now
597
+ if ttl:
598
+ c["ttl"] = ttl
599
+ save_agent(ws, agent, mine)
600
+ if args.json:
601
+ emit_json({"ok": bool(mine), "agent": agent, "renewed": len(mine)})
602
+ elif mine:
603
+ print("OK: renewed %d claim(s) for %s." % (len(mine), agent))
604
+ else:
605
+ print("NOTHING HELD: %s has no live claims. They may have expired; claim again before editing." % agent)
606
+ return EXIT_OK if mine else EXIT_CONFLICT
607
+
608
+
609
+ def cmd_note(ws, args):
610
+ agent = need_agent(args)
611
+ text = " ".join(args.text).strip()
612
+ if not text:
613
+ raise UsageError("note text is empty")
614
+ entry = {"ts": time.time(), "agent": agent, "text": text,
615
+ "paths": sorted({normalize(ws, p) for p in (args.paths or [])})}
616
+ with locked(ws):
617
+ with open(ws.log_path, "a", encoding="utf-8") as f:
618
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
619
+ if args.json:
620
+ emit_json({"ok": True, "note": entry})
621
+ else:
622
+ print("OK: note saved.")
623
+ return EXIT_OK
624
+
625
+
626
+ def cmd_log(ws, args):
627
+ entries = []
628
+ with locked(ws):
629
+ if os.path.exists(ws.log_path):
630
+ with open(ws.log_path, encoding="utf-8") as f:
631
+ for line in f:
632
+ with contextlib.suppress(ValueError):
633
+ e = json.loads(line)
634
+ if isinstance(e, dict) and isinstance(e.get("ts"), (int, float)) and e.get("text"):
635
+ entries.append(e)
636
+ entries = entries[-max(1, args.n):]
637
+ if args.json:
638
+ emit_json(entries)
639
+ return EXIT_OK
640
+ if not entries:
641
+ print("No notes yet.")
642
+ now = time.time()
643
+ for e in entries:
644
+ paths = (" [%s]" % ", ".join(e["paths"])) if e.get("paths") else ""
645
+ print("%s ago %s: %s%s" % (ago(now - e["ts"]), e.get("agent", "?"), e["text"], paths))
646
+ return EXIT_OK
647
+
648
+
649
+ def cmd_gc(ws, args):
650
+ with locked(ws):
651
+ _, removed = drop_expired(ws, load_all(ws), time.time())
652
+ for name in os.listdir(ws.claims_dir): # leftovers of a process killed mid-write
653
+ path = os.path.join(ws.claims_dir, name)
654
+ if name.startswith(".tmp-") and time.time() - os.path.getmtime(path) > 60:
655
+ with contextlib.suppress(OSError):
656
+ os.unlink(path)
657
+ if args.json:
658
+ emit_json({"ok": True, "removed": removed})
659
+ else:
660
+ print("Removed %d expired claim(s)." % removed)
661
+ return EXIT_OK
662
+
663
+
664
+ def cmd_evict(ws, args):
665
+ """Human override: drop another agent's claims (e.g. it crashed and you
666
+ don't want to wait for the TTL)."""
667
+ target = args.agent.strip().lower()
668
+ with locked(ws):
669
+ count = sum(len(c["paths"]) for c in load_all(ws).get(target, []))
670
+ save_agent(ws, target, [])
671
+ if args.json:
672
+ emit_json({"ok": True, "evicted": target, "paths": count})
673
+ else:
674
+ print("Evicted %s (%d path(s))." % (target, count))
675
+ return EXIT_OK
676
+
677
+
678
+ # ---------------------------------------------------------------------------
679
+ # CLI
680
+ # ---------------------------------------------------------------------------
681
+
682
+ def build_parser():
683
+ p = argparse.ArgumentParser(
684
+ prog="pa",
685
+ description="Keep parallel coding agents out of each other's way: claim files before editing them.",
686
+ epilog="Exit codes: 0 ok, 1 error, 2 usage error (nothing was claimed), 3 blocked or nothing held.",
687
+ )
688
+ p.add_argument("--version", action="version", version="parallel-agents " + VERSION)
689
+ sub = p.add_subparsers(dest="command", metavar="<command>")
690
+
691
+ def add(name, help_text, func):
692
+ sp = sub.add_parser(name, help=help_text, description=help_text)
693
+ sp.set_defaults(func=func)
694
+ sp.add_argument("--json", action="store_true", help="machine-readable output")
695
+ return sp
696
+
697
+ def who(sp, required=True):
698
+ sp.add_argument("--as", dest="as_", metavar="NAME",
699
+ help="your agent name, e.g. claude-7f2c (or set PA_AS)" if required
700
+ else "your agent name; your own claims never count as taken")
701
+
702
+ add("init", "Create the shared state directory and show where it is.", cmd_init)
703
+
704
+ sp = add("claim", "Claim files, folders, globs or @resources before editing them.", cmd_claim)
705
+ sp.add_argument("paths", nargs="+", metavar="PATH")
706
+ who(sp)
707
+ sp.add_argument("--why", help="what you are about to do (shown to other agents)")
708
+ sp.add_argument("--ttl", help="how long the claim lives without renewal (default 30m)")
709
+
710
+ sp = add("release", "Release some or all of your claims.", cmd_release)
711
+ sp.add_argument("paths", nargs="*", metavar="PATH")
712
+ who(sp)
713
+
714
+ sp = add("check", "Exit 0 if free (or yours with --as), 3 if another agent holds it.", cmd_check)
715
+ sp.add_argument("paths", nargs="+", metavar="PATH")
716
+ who(sp, required=False)
717
+
718
+ add("status", "Show who holds what right now.", cmd_status)
719
+
720
+ sp = add("renew", "Keep your claims alive during long tasks (exit 3 if you hold nothing).", cmd_renew)
721
+ who(sp)
722
+ sp.add_argument("--ttl", help="new lifetime, e.g. 1h")
723
+
724
+ sp = add("note", "Leave a handoff note for the next agent.", cmd_note)
725
+ sp.add_argument("text", nargs="+")
726
+ who(sp)
727
+ sp.add_argument("--paths", nargs="*", metavar="PATH", help="files the note is about")
728
+
729
+ sp = add("log", "Read recent handoff notes.", cmd_log)
730
+ sp.add_argument("-n", type=int, default=10, help="how many (default 10)")
731
+
732
+ add("gc", "Delete expired claims and leftover temp files.", cmd_gc)
733
+
734
+ sp = add("evict", "Human override: drop all claims of another agent.", cmd_evict)
735
+ sp.add_argument("agent")
736
+
737
+ return p
738
+
739
+
740
+ def main(argv=None):
741
+ parser = build_parser()
742
+ args = parser.parse_args(argv)
743
+ if not getattr(args, "func", None):
744
+ parser.print_help()
745
+ return EXIT_USAGE
746
+ try:
747
+ return args.func(Workspace.discover(), args)
748
+ except UsageError as e:
749
+ if getattr(args, "json", False):
750
+ emit_json({"ok": False, "error": str(e)})
751
+ print("error: %s" % e, file=sys.stderr)
752
+ return EXIT_USAGE
753
+ except OSError as e:
754
+ if getattr(args, "json", False):
755
+ emit_json({"ok": False, "error": str(e)})
756
+ print("error: %s" % e, file=sys.stderr)
757
+ return EXIT_ERROR
758
+
759
+
760
+ if __name__ == "__main__":
761
+ sys.exit(main())
pa_hook.py ADDED
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env python3
2
+ """pa-hook - enforce parallel-agents claims from inside the agent.
3
+
4
+ Claude Code runs this as a PreToolUse hook. Before an agent writes to a file
5
+ the hook claims that path; if another agent already holds it, the edit is
6
+ denied and the agent is told who holds it and why. The protocol then holds
7
+ even for an agent that never loaded the skill.
8
+
9
+ Reads the hook payload on stdin, writes a PreToolUse decision on stdout.
10
+
11
+ Anything unexpected lets the edit through. A coordination helper must never be
12
+ the reason a session stops working, so a broken payload, a missing pa.py or a
13
+ slow claim all fall back to allowing the edit.
14
+
15
+ Environment:
16
+ PA_AS this agent's name (default: claude-<session id>)
17
+ PA_HOOK_DISABLE set to anything to turn the hook off
18
+ PA_HOOK_MODE "claim" (default) also takes the path; "check" only blocks
19
+ PA_HOOK_TTL how long an auto-claim lives without a further edit (15m)
20
+ """
21
+
22
+ import json
23
+ import os
24
+ import subprocess
25
+ import sys
26
+
27
+ EDITING_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
28
+ PATH_KEYS = ("file_path", "notebook_path")
29
+ PA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pa.py")
30
+ DEFAULT_TTL = "15m"
31
+ EXIT_CONFLICT = 3
32
+ TIMEOUT = 10
33
+
34
+
35
+ def allow():
36
+ """Say nothing: Claude Code then decides the call on its own rules."""
37
+ sys.exit(0)
38
+
39
+
40
+ def deny(reason):
41
+ json.dump({"hookSpecificOutput": {
42
+ "hookEventName": "PreToolUse",
43
+ "permissionDecision": "deny",
44
+ "permissionDecisionReason": reason,
45
+ }}, sys.stdout)
46
+ sys.exit(0)
47
+
48
+
49
+ def wanted_paths(tool_input):
50
+ out = []
51
+ for key in PATH_KEYS:
52
+ value = tool_input.get(key)
53
+ if isinstance(value, str) and value.strip():
54
+ out.append(value)
55
+ return out
56
+
57
+
58
+ def agent_name(payload):
59
+ """This agent's name: PA_AS, else one derived from the session id.
60
+
61
+ Two Claude Code sessions on one repo get different session ids, which is
62
+ exactly the identity the board needs. pa.py requires [a-z0-9][a-z0-9._-]*.
63
+ """
64
+ name = (os.environ.get("PA_AS") or "").strip()
65
+ if name:
66
+ return name
67
+ session = "".join(c for c in str(payload.get("session_id") or "") if c.isalnum())
68
+ return ("claude-" + session[:8].lower()) if session else "claude"
69
+
70
+
71
+ def explain(stdout, paths):
72
+ holders = []
73
+ try:
74
+ for conflict in (json.loads(stdout).get("conflicts") or []):
75
+ who = conflict.get("agent") or "another agent"
76
+ held = ", ".join(conflict.get("paths") or []) or conflict.get("requested") or ""
77
+ why = conflict.get("why") or ""
78
+ holders.append("%s holds %s%s" % (who, held, ' "%s"' % why if why else ""))
79
+ except (ValueError, AttributeError):
80
+ pass
81
+ lines = ["parallel-agents: %s is claimed by another agent." % ", ".join(paths)]
82
+ lines.extend(" " + h for h in holders)
83
+ lines.append("Do not edit these paths. Work on something else and come back to them, "
84
+ "or ask the user. `pa status` shows the whole board.")
85
+ return "\n".join(lines)
86
+
87
+
88
+ def main():
89
+ if os.environ.get("PA_HOOK_DISABLE"):
90
+ allow()
91
+ try:
92
+ payload = json.load(sys.stdin)
93
+ except (ValueError, OSError):
94
+ allow()
95
+ if not isinstance(payload, dict) or payload.get("tool_name") not in EDITING_TOOLS:
96
+ allow()
97
+
98
+ tool_input = payload.get("tool_input")
99
+ if not isinstance(tool_input, dict):
100
+ allow()
101
+ paths = wanted_paths(tool_input)
102
+ if not paths or not os.path.exists(PA):
103
+ allow()
104
+
105
+ check_only = (os.environ.get("PA_HOOK_MODE") or "").strip().lower() == "check"
106
+ argv = [sys.executable, PA, "check" if check_only else "claim"]
107
+ argv += paths + ["--as", agent_name(payload), "--json"]
108
+ if not check_only:
109
+ argv += ["--why", "%s via Claude Code" % payload["tool_name"],
110
+ "--ttl", (os.environ.get("PA_HOOK_TTL") or DEFAULT_TTL).strip()]
111
+
112
+ try:
113
+ done = subprocess.run(argv, cwd=payload.get("cwd") or None,
114
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
115
+ universal_newlines=True, timeout=TIMEOUT)
116
+ except (OSError, subprocess.SubprocessError):
117
+ allow()
118
+
119
+ # Only a conflict blocks. A usage or disk error means the board is not
120
+ # answering, and a board that cannot answer must not hold the agent up.
121
+ if done.returncode != EXIT_CONFLICT:
122
+ allow()
123
+ deny(explain(done.stdout, paths))
124
+
125
+
126
+ if __name__ == "__main__":
127
+ main()
@@ -0,0 +1,171 @@
1
+ Metadata-Version: 2.4
2
+ Name: parallel-agents-cli
3
+ Version: 0.1.0
4
+ Summary: Run Claude Code, Codex, Cursor and Gemini CLI on the same repo without them overwriting each other.
5
+ Author-email: Alisher Muhammadaliyev <viltrumlik@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Viltrumlik/parallel-agents
8
+ Project-URL: Source, https://github.com/Viltrumlik/parallel-agents
9
+ Project-URL: Issues, https://github.com/Viltrumlik/parallel-agents/issues
10
+ Project-URL: Changelog, https://github.com/Viltrumlik/parallel-agents/blob/main/CHANGELOG.md
11
+ Keywords: claude-code,codex,cursor,gemini-cli,multi-agent,coordination,file-locking,agent-skills
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Topic :: Software Development :: Version Control :: Git
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ <p align="center">
25
+ <img src="https://raw.githubusercontent.com/Viltrumlik/parallel-agents/main/.claude-plugin/icon.png" width="112" alt="parallel-agents icon">
26
+ </p>
27
+
28
+ <h1 align="center">parallel-agents</h1>
29
+
30
+ <p align="center">
31
+ <b>Run Claude Code, Codex, Cursor and Gemini CLI on the same repo without them overwriting each other.</b>
32
+ </p>
33
+
34
+ <p align="center">
35
+ <a href="https://github.com/Viltrumlik/parallel-agents/actions/workflows/ci.yml"><img src="https://github.com/Viltrumlik/parallel-agents/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
36
+ <img src="https://img.shields.io/badge/Agent%20Skills-SKILL.md-7c3aed" alt="Agent Skills">
37
+ <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="Zero dependencies">
38
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT"></a>
39
+ </p>
40
+
41
+ <p align="center">
42
+ <img src="https://raw.githubusercontent.com/Viltrumlik/parallel-agents/main/demo/demo.gif" alt="Two agents share a repo: the second agent is blocked from a folder the first one claimed, works on tests instead, and takes the file once the first agent releases it" width="900">
43
+ </p>
44
+
45
+ Running several coding agents at once is the new normal. In one checkout, though, they collide: two agents edit the same file, one runs migrations while the other changes models, both start a dev server on port 3000. **parallel-agents** gives them a tiny shared protocol: **claim before you edit, see who holds what, leave a note, release.**
46
+
47
+ It's an [Agent Skill](https://agentskills.io), a `SKILL.md` plus one zero-dependency Python script, so it works in every agent that reads skills. In Claude Code a hook enforces it, so an agent that never read the skill still can't overwrite another one's work.
48
+
49
+ ## How it works
50
+
51
+ ```bash
52
+ pa status # who is doing what
53
+ pa claim src/billing/ --as claude-7f2c --why "add refund endpoint" # before the first edit
54
+ pa claim @migrations --as claude-7f2c --why "add refunds table" # shared operations too
55
+ pa note "refunds done, tests still missing" --as claude-7f2c # handoff for the next agent
56
+ pa release --as claude-7f2c # done
57
+ ```
58
+
59
+ If another agent already holds something, `pa claim` refuses (exit code `3`), says who holds it and why, and the agent works on something else instead.
60
+
61
+ - **Enforced in Claude Code.** A `PreToolUse` hook claims each path before the edit and refuses the edit outright if another agent holds it. Everywhere else the skill carries the protocol.
62
+ - **Atomic.** Checking and claiming happen under one file lock. If 16 agents claim the same file at the same moment, exactly one wins (there's a test for that).
63
+ - **Never stuck.** Claims expire after 30 minutes without activity, so a crashed agent can't block the repo. Agents refresh them by re-claiming before each batch of edits.
64
+ - **Conservative.** Any doubt counts as overlap: paths compare case-insensitively, symlinks are resolved, a folder that doesn't exist yet still covers what will be created in it, and `src/**/*.py` collides with `src/app.py`.
65
+ - **Invisible to git.** State lives in the repo's git directory (`.git/parallel-agents/`). Nothing to commit, nothing to `.gitignore`. Outside git it falls back to a `.parallel-agents/` folder.
66
+ - **Worktree-aware.** All worktrees of a repo share the state. Editing the same file in another worktree isn't blocked, but you get a warning about the merge conflict ahead.
67
+ - **Files, folders, globs and resources.** `src/api/views.py`, `src/api/`, `src/**/*.py`, plus named resources like `@migrations`, `@deps`, `@port:3000` and `@db`.
68
+
69
+ ## Install
70
+
71
+ **Requirements:** Python 3.9+ on macOS, Linux or Windows. Nothing to `pip install`. Git is optional: inside a repo the state goes into the git directory, outside one into `.parallel-agents/`.
72
+
73
+ **Any agent** (Claude Code, Codex, Cursor, Gemini CLI, Copilot…) with the [skills CLI](https://skills.sh):
74
+
75
+ ```bash
76
+ npx skills add Viltrumlik/parallel-agents
77
+ ```
78
+
79
+ **Claude Code** plugin marketplace:
80
+
81
+ ```
82
+ /plugin marketplace add Viltrumlik/parallel-agents
83
+ /plugin install parallel-agents@parallel-agents
84
+ ```
85
+
86
+ Installed as a plugin, the protocol is **enforced**: a `PreToolUse` hook runs before every `Edit`, `Write`, `MultiEdit` and `NotebookEdit`, claims the path for this session, and denies the edit — naming who holds it and why — if another agent got there first. Each Claude Code session is its own agent, named after its session id, so two sessions on one repo coordinate with no setup.
87
+
88
+ | Variable | Default | What it does |
89
+ |---|---|---|
90
+ | `PA_AS` | `claude-<session id>` | this agent's name on the board |
91
+ | `PA_HOOK_TTL` | `15m` | how long an auto-claim lives without a further edit |
92
+ | `PA_HOOK_MODE` | `claim` | `check` blocks without taking the path |
93
+ | `PA_HOOK_DISABLE` | unset | set to anything to turn the hook off |
94
+
95
+ If anything goes wrong — a payload it doesn't understand, a missing `pa.py`, a board that won't answer — the edit goes through. A coordination helper is never the reason a session stops working.
96
+
97
+ **Manually:** copy [`skills/parallel-agents`](skills/parallel-agents) into your agent's skills folder: `~/.claude/skills/`, `~/.codex/skills/`, `~/.gemini/skills/`, or `.agents/skills/` inside a project.
98
+
99
+ **Just the `pa` command**, to drive the board yourself from a terminal:
100
+
101
+ ```bash
102
+ pipx install parallel-agents-cli
103
+ ```
104
+
105
+ ### Make it always-on
106
+
107
+ Skills load when an agent thinks they're relevant. To make sure every agent follows the protocol from its first edit, paste [`snippets/AGENTS.md`](snippets/AGENTS.md) into your repo's `AGENTS.md` (Codex, Cursor, Gemini) or `CLAUDE.md`.
108
+
109
+ ## Commands
110
+
111
+ | Command | What it does |
112
+ |---|---|
113
+ | `pa status [--json]` | Who holds what right now |
114
+ | `pa claim PATH... --as NAME --why TEXT [--ttl 30m]` | Claim before editing. All or nothing; exit `3` if blocked |
115
+ | `pa check PATH... [--as NAME]` | Exit `0` if free (or yours), `3` if taken |
116
+ | `pa release [PATH...] --as NAME` | Release some paths, or all of yours |
117
+ | `pa renew --as NAME [--ttl 1h]` | Keep claims alive; exit `3` if nothing is held anymore |
118
+ | `pa note TEXT --as NAME [--paths ...]` | Leave a handoff note |
119
+ | `pa log [-n 10] [--json]` | Read recent notes |
120
+ | `pa gc` | Delete expired claims |
121
+ | `pa evict AGENT` | Human override: drop an agent's claims |
122
+
123
+ Every command accepts `--json`, so agents can parse the output. You can set `PA_AS` instead of passing `--as`.
124
+
125
+ Exit codes: `0` ok · `1` error (disk, lock) · `2` wrong command, nothing was claimed · `3` blocked by another agent, or nothing held.
126
+
127
+ ## FAQ
128
+
129
+ **Why not just use git worktrees?** Use both. Worktrees isolate files, but the agents still share your database, ports and lockfile, and you meet their overlapping edits later as merge conflicts. parallel-agents coordinates the shared resources and warns about overlaps across worktrees while there's still time to split the work.
130
+
131
+ **Is it enforced?** In Claude Code, yes. Install it as a plugin and a `PreToolUse` hook runs before every edit: it claims the path for this session, and refuses the edit if another agent holds it. An agent that never loaded the skill still cannot overwrite another one's work. Everywhere else it's an advisory protocol that agents follow through the skill — Cursor and Codex hooks are next.
132
+
133
+ **Does it work across machines?** No, it coordinates agents that share one filesystem. That covers the common setup of several agents on your laptop or one dev box.
134
+
135
+ **Will it slow my agents down?** Each command is a single short Python process, and the skill tells agents to claim once per batch of edits, not once per edit. In Claude Code the skill adds about 130 tokens to a session and about 1,200 when it is used (`claude plugin details`).
136
+
137
+ ## Roadmap
138
+
139
+ - [x] Hooks that block edits to files another agent holds — Claude Code done, Cursor and Codex next
140
+ - [ ] `pa watch`: a live terminal view of who holds what
141
+ - [ ] Editor status-bar integrations
142
+ - [ ] Claims that follow git branches
143
+
144
+ Ideas and PRs are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
145
+
146
+ ## Development
147
+
148
+ ```bash
149
+ python3 -m unittest discover -s tests -v # the full test suite, including the 16-agent race
150
+ bash demo/two-agents.sh # two agents sharing a throwaway repo, step by step
151
+ ```
152
+
153
+ CI runs the tests on Ubuntu, macOS and Windows with Python 3.9, 3.10 and 3.13.
154
+
155
+ ```
156
+ skills/parallel-agents/
157
+ ├── SKILL.md # the protocol agents read
158
+ └── scripts/
159
+ ├── pa.py # the `pa` CLI: one file, zero dependencies
160
+ └── pa_hook.py # Claude Code PreToolUse hook
161
+ pyproject.toml # the PyPI package: pipx install parallel-agents-cli
162
+ hooks/hooks.json # registers the hook when installed as a plugin
163
+ .claude-plugin/ # plugin and marketplace manifests
164
+ snippets/AGENTS.md # always-on instructions for AGENTS.md / CLAUDE.md
165
+ tests/ # end-to-end tests against real git repos
166
+ demo/ # demo script and GIF generator
167
+ ```
168
+
169
+ ## License
170
+
171
+ [MIT](LICENSE) © Alisher ([@Viltrumlik](https://github.com/Viltrumlik))
@@ -0,0 +1,8 @@
1
+ pa.py,sha256=bgWU-8vmaVK2-4PzhWUs96_NCmHOgngdjMDzqCwfa-A,28109
2
+ pa_hook.py,sha256=wRfH5XW_-zjjjCEZ7I0e6lyZFSPv6xZCQ4gEAk0dsjE,4469
3
+ parallel_agents_cli-0.1.0.dist-info/licenses/LICENSE,sha256=WHxEksSGrdAAA6UbFYxEom_yI8krJiy_80b5zhR593M,1077
4
+ parallel_agents_cli-0.1.0.dist-info/METADATA,sha256=KGym0J036ZUc5r5PYxY8RxzkGWoDmTTUOzJOsASq9OY,10265
5
+ parallel_agents_cli-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
6
+ parallel_agents_cli-0.1.0.dist-info/entry_points.txt,sha256=MvhLx53YpF252CkscQcYkKWNxRwbszrliX74Ed6FDXE,31
7
+ parallel_agents_cli-0.1.0.dist-info/top_level.txt,sha256=6ad4h9HwhEXBr52XNSkTs711sWIYnhqrk6f1D9lD_-w,11
8
+ parallel_agents_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pa = pa:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alisher (Viltrumlik)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ pa
2
+ pa_hook