@codemeall/agent-fleet 0.1.0-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.claude-plugin/plugin.json +14 -0
  2. package/CHANGELOG.md +23 -0
  3. package/LICENSE +21 -0
  4. package/README.md +229 -0
  5. package/bin/build.js +5 -0
  6. package/bin/fleet.js +27 -0
  7. package/bin/setup.js +138 -0
  8. package/config.example.toml +21 -0
  9. package/dist/codex/agent-fleet/.codex-plugin/plugin.json +23 -0
  10. package/dist/codex/agent-fleet/skills/fleet/LICENSE +21 -0
  11. package/dist/codex/agent-fleet/skills/fleet/SKILL.md +82 -0
  12. package/dist/codex/agent-fleet/skills/fleet/agents/openai.yaml +6 -0
  13. package/dist/codex/agent-fleet/skills/fleet/bin/fleet +1028 -0
  14. package/dist/codex/agent-fleet/skills/fleet/providers.toml +106 -0
  15. package/dist/codex/agent-fleet/skills/fleet/references/harnesses.md +24 -0
  16. package/dist/codex/agent-fleet/skills/fleet/references/providers.md +31 -0
  17. package/dist/codex/agent-fleet/skills/fleet/references/routing.md +40 -0
  18. package/dist/codex/agent-fleet/skills/fleet/templates/preamble.md +16 -0
  19. package/dist/codex/agent-fleet/skills/fleet/templates/report.md +26 -0
  20. package/dist/codex/agent-fleet/skills/fleet/templates/review-prompt.md +14 -0
  21. package/dist/codex/agent-fleet/skills/fleet/templates/ticket-prompt.md +18 -0
  22. package/examples/README.md +58 -0
  23. package/examples/plan.json +12 -0
  24. package/examples/rules.md +25 -0
  25. package/examples/tickets/glossary.md +12 -0
  26. package/examples/tickets/guide.md +13 -0
  27. package/examples/tickets/overview.md +13 -0
  28. package/install.sh +9 -0
  29. package/package.json +63 -0
  30. package/plugin-manifests/codex.json +23 -0
  31. package/skill/LICENSE +21 -0
  32. package/skill/SKILL.md +83 -0
  33. package/skill/agents/openai.yaml +6 -0
  34. package/skill/bin/fleet +1028 -0
  35. package/skill/providers.toml +106 -0
  36. package/skill/references/harnesses.md +24 -0
  37. package/skill/references/providers.md +31 -0
  38. package/skill/references/routing.md +40 -0
  39. package/skill/templates/preamble.md +16 -0
  40. package/skill/templates/report.md +26 -0
  41. package/skill/templates/review-prompt.md +14 -0
  42. package/skill/templates/ticket-prompt.md +18 -0
@@ -0,0 +1,1028 @@
1
+ #!/usr/bin/env python3
2
+ """fleet: the mechanics of running coding agents in cmux panes.
3
+
4
+ The lead agent decides what to run; this tool launches, watches and stops it.
5
+ Standard library only (Python 3.11+), so any harness with a shell can drive it.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import base64
11
+ import difflib
12
+ import fcntl
13
+ import hashlib
14
+ import uuid
15
+ from contextlib import contextmanager
16
+ import json
17
+ import os
18
+ import re
19
+ import shlex
20
+ import shutil
21
+ import subprocess
22
+ import sys
23
+ import time
24
+ import tomllib
25
+ from pathlib import Path
26
+
27
+ SKILL_DIR = Path(__file__).resolve().parent.parent
28
+ USER_CONFIG = Path(os.environ.get("FLEET_CONFIG", "~/.config/agent-fleet/config.toml")).expanduser()
29
+ HARNESS_SKILL_DIRS = {
30
+ "claude": "~/.claude/skills",
31
+ "claude-co": "~/.claude-co/skills",
32
+ "codex": "~/.agents/skills",
33
+ "cursor": "~/.cursor/skills",
34
+ }
35
+ DEFAULTS = {
36
+ "workspace": None,
37
+ "routing": "auto",
38
+ "prefer": ["claude", "codex", "cursor"],
39
+ "max_parallel": 6,
40
+ "review": "cross-heavy",
41
+ }
42
+ STATUS_RE = re.compile(r"^\s*[*_`]*\s*status\s*[*_`]*\s*:\s*[*_`]*\s*(.+?)\s*[*_`]*\s*$", re.I)
43
+ SURFACE_RE = re.compile(r"surface:\d+")
44
+
45
+
46
+ class FleetError(Exception):
47
+ pass
48
+
49
+
50
+ # ---------- config ----------
51
+
52
+ def deep_merge(base: dict, over: dict) -> dict:
53
+ out = dict(base)
54
+ for key, value in over.items():
55
+ if isinstance(value, dict) and isinstance(out.get(key), dict):
56
+ out[key] = deep_merge(out[key], value)
57
+ else:
58
+ out[key] = value
59
+ return out
60
+
61
+
62
+ def load_config(user_path: Path = USER_CONFIG) -> dict:
63
+ with open(SKILL_DIR / "providers.toml", "rb") as fh:
64
+ cfg = tomllib.load(fh)
65
+ if user_path.exists():
66
+ with open(user_path, "rb") as fh:
67
+ cfg = deep_merge(cfg, tomllib.load(fh))
68
+ cfg["defaults"] = deep_merge(DEFAULTS, cfg.get("defaults", {}))
69
+ if not isinstance(cfg["defaults"]["max_parallel"], int) or cfg["defaults"]["max_parallel"] < 1:
70
+ raise FleetError("defaults.max_parallel must be a positive integer")
71
+ if cfg["defaults"]["review"] not in ("off", "cross-heavy", "cross-all"):
72
+ raise FleetError("invalid defaults.review")
73
+ for name, adapter in cfg["providers"].items():
74
+ if not isinstance(adapter.get("max", 2), int) or adapter.get("max", 2) < 1:
75
+ raise FleetError(f"{name}.max must be a positive integer")
76
+ return cfg
77
+
78
+
79
+ def provider(cfg: dict, name: str) -> dict:
80
+ p = cfg.get("providers", {}).get(name)
81
+ if p is None:
82
+ raise FleetError(f"unknown provider '{name}'. Known: {', '.join(cfg.get('providers', {}))}")
83
+ return p
84
+
85
+
86
+ # ---------- pure helpers ----------
87
+
88
+ def resolve_model(pcfg: dict, tier: str | None, model: str | None, effort: str | None) -> tuple[str, str | None]:
89
+ tier_cfg = pcfg.get("tiers", {}).get(tier or "standard", {})
90
+ model = model or tier_cfg.get("model")
91
+ if not model:
92
+ raise FleetError(f"no model: pass --model or define tiers.{tier or 'standard'} for this provider")
93
+ return model, effort or tier_cfg.get("effort")
94
+
95
+
96
+ def build_launch(pcfg: dict, model: str, effort: str | None, prompt: str) -> str:
97
+ template = pcfg["launch"]
98
+ if "{effort}" in template and not effort:
99
+ raise FleetError("this provider's launch needs an effort: pass --effort or set it on the tier")
100
+ return template.format(bin=pcfg["bin"], model=shlex.quote(model), effort=shlex.quote(effort or ""), prompt=shlex.quote(prompt))
101
+
102
+
103
+ def worker_prompt_line(prompt_path: Path, ticket_id: str) -> str:
104
+ return (f"Read {prompt_path} and follow it exactly. You are fleet worker {ticket_id}. "
105
+ f"Your report path and hard rules are in that file.")
106
+
107
+
108
+ def parse_status(text: str) -> str | None:
109
+ for line in text.splitlines():
110
+ m = STATUS_RE.match(line)
111
+ if m:
112
+ return m.group(1).strip("*_` ").strip()
113
+ return None
114
+
115
+
116
+ def compare_snapshots(before: dict, after: dict) -> list[str]:
117
+ problems = []
118
+ for path, sha in before["heads"].items():
119
+ now = after["heads"].get(path)
120
+ if now != sha:
121
+ problems.append(f"HEAD moved in {path}: {sha[:10]} -> {(now or 'missing')[:10]}")
122
+ for path in before["staged"].keys() | after["staged"].keys():
123
+ old, new = before["staged"].get(path, []), after["staged"].get(path, [])
124
+ if old != new:
125
+ problems.append(f"index changed in {path}: " + ", ".join(sorted(set(new) ^ set(old))[:10]))
126
+ return problems
127
+
128
+
129
+ # ---------- shell ----------
130
+
131
+ def sh(args: list[str] | str, *, cwd: Path | None = None, timeout: int = 60, check: bool = True) -> str:
132
+ res = subprocess.run(args, cwd=cwd, capture_output=True, text=True, timeout=timeout,
133
+ shell=isinstance(args, str))
134
+ if check and res.returncode != 0:
135
+ cmd = args if isinstance(args, str) else " ".join(args)
136
+ raise FleetError(f"`{cmd}` failed ({res.returncode}): {(res.stderr or res.stdout).strip()}")
137
+ return res.stdout
138
+
139
+
140
+ def cmux(*args: str, timeout: int = 30) -> str:
141
+ return sh(["cmux", *args], timeout=timeout)
142
+
143
+
144
+ def repo_root(start: Path | None = None) -> Path:
145
+ return Path(sh(["git", "rev-parse", "--show-toplevel"], cwd=start or Path.cwd()).strip())
146
+
147
+
148
+ def git_snapshot(repo: Path) -> dict:
149
+ paths = ["."]
150
+ out = sh(["git", "submodule", "foreach", "--quiet", "--recursive", "echo $displaypath"], cwd=repo, check=False)
151
+ paths += [p for p in out.splitlines() if p.strip()]
152
+ heads, staged = {}, {}
153
+ for p in paths:
154
+ d = repo / p
155
+ heads[p] = sh(["git", "rev-parse", "HEAD"], cwd=d, check=False).strip()
156
+ staged[p] = sorted(x for x in sh(["git", "ls-files", "--stage", "-z"], cwd=d).split("\0") if x)
157
+ return {"heads": heads, "staged": staged}
158
+
159
+
160
+ # ---------- run state ----------
161
+
162
+ def run_dir(repo: Path, run: str) -> Path:
163
+ valid_id(run)
164
+ root = contained(repo.resolve(), repo.resolve() / ".fleet" / "runs")
165
+ return contained(root, root / run)
166
+
167
+
168
+ def load_run(repo: Path, run: str) -> dict:
169
+ f = run_dir(repo, run) / "run.json"
170
+ if not f.exists():
171
+ raise FleetError(f"no run '{run}' in {repo}. Start one with: fleet init {run}")
172
+ state = json.loads(f.read_text())
173
+ if state.get("schema") != 2:
174
+ raise FleetError("legacy run: stop and inspect its workers before starting a new run; baseline cannot be upgraded safely")
175
+ if Path(state["repo"]).resolve() != repo.resolve():
176
+ raise FleetError("run belongs to a different checkout")
177
+ return state
178
+
179
+
180
+ def save_run(repo: Path, run: str, state: dict) -> None:
181
+ atomic_json(run_dir(repo, run) / "run.json", state)
182
+
183
+
184
+ def worker(state: dict, ticket_id: str) -> dict:
185
+ valid_id(ticket_id)
186
+ w = state["workers"].get(ticket_id)
187
+ if not w:
188
+ raise FleetError(f"no worker '{ticket_id}' in this run")
189
+ return w
190
+
191
+
192
+ def active(state: dict) -> list[dict]:
193
+ return [w for w in state["workers"].values() if w.get("state") in ACTIVE_STATES]
194
+
195
+
196
+ # ---------- durable orchestration helpers ----------
197
+
198
+ ACTIVE_STATES = {"launching", "running", "stop-failed", "unknown"}
199
+ ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}\Z")
200
+
201
+
202
+ def valid_id(value: str) -> str:
203
+ if not isinstance(value, str) or not ID_RE.fullmatch(value):
204
+ raise FleetError("run/worker IDs must be 1–64 letters, numbers, underscores or hyphens; start with a letter or number")
205
+ return value
206
+
207
+
208
+ def contained(root: Path, path: Path) -> Path:
209
+ # Reject symlinks as well as lexical traversal; run state must remain in the checkout.
210
+ root = Path(root)
211
+ if not path.resolve().is_relative_to(root.resolve()):
212
+ raise FleetError(f"path escapes its allowed directory: {path}")
213
+ cursor = path
214
+ while cursor != cursor.parent:
215
+ if cursor.is_symlink():
216
+ raise FleetError(f"symlink path is not supported: {cursor}")
217
+ if cursor.resolve() == root.resolve():
218
+ break
219
+ cursor = cursor.parent
220
+ return path
221
+
222
+
223
+ def safe_file(repo: Path, value: str) -> str:
224
+ path = Path(value)
225
+ if path.is_absolute() or not path.parts or ".." in path.parts or any(c in value for c in "*?\n\r"):
226
+ raise FleetError(f"scope must name an exact relative file: {value}")
227
+ if any(part in (".git", ".fleet") or part.startswith(".env") for part in path.parts):
228
+ raise FleetError(f"protected file cannot be a worker scope: {value}")
229
+ full = contained(repo.resolve(), repo / path)
230
+ if full.is_dir():
231
+ raise FleetError(f"scope must name files, not directories: {value}")
232
+ return path.as_posix()
233
+
234
+
235
+ def atomic_json(path: Path, data: dict) -> None:
236
+ contained(path.parent, path)
237
+ temp = path.with_name(path.name + "." + uuid.uuid4().hex + ".tmp")
238
+ try:
239
+ temp.write_text(json.dumps(data, indent=2) + "\n")
240
+ temp.replace(path)
241
+ finally:
242
+ temp.unlink(missing_ok=True)
243
+
244
+
245
+ @contextmanager
246
+ def mutation_lock(a):
247
+ if not hasattr(a, "run") or a.cmd == "wait":
248
+ yield
249
+ return
250
+ repo = repo_root()
251
+ root = contained(repo.resolve(), repo / ".fleet" / "runs")
252
+ root.mkdir(parents=True, exist_ok=True)
253
+ lock_path = contained(root, root / ".lock")
254
+ with lock_path.open("a") as lock:
255
+ try:
256
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
257
+ except BlockingIOError:
258
+ raise FleetError("another Fleet command is changing this checkout; retry when it finishes")
259
+ try:
260
+ yield
261
+ finally:
262
+ fcntl.flock(lock, fcntl.LOCK_UN)
263
+
264
+
265
+ def routing_pool(routing: str, cfg: dict) -> dict | None:
266
+ if routing == "auto":
267
+ return None
268
+ if routing.startswith("single:"):
269
+ name = routing.removeprefix("single:")
270
+ provider(cfg, name)
271
+ return {name: None}
272
+ if routing.startswith("agents="):
273
+ pool = {}
274
+ for item in routing.removeprefix("agents=").split(","):
275
+ name, sep, model = item.partition(":")
276
+ provider(cfg, name)
277
+ pool[name] = model if sep else None
278
+ return pool
279
+ raise FleetError("routing must be auto, single:<provider> or agents=<provider>[:<model>],...")
280
+
281
+
282
+ def model_family(pcfg: dict, tier: str, model: str, explicit: str | None = None) -> str | None:
283
+ if explicit:
284
+ return explicit
285
+ mapped = pcfg.get("model_families", {}).get(model)
286
+ if mapped:
287
+ return mapped
288
+ t = pcfg.get("tiers", {}).get(tier, {})
289
+ if t.get("model") == model and t.get("family"):
290
+ return t["family"]
291
+ family = pcfg.get("family")
292
+ return family if family and family not in ("mixed", "unknown") else None
293
+
294
+
295
+ def digest(data: bytes) -> str:
296
+ return hashlib.sha256(data).hexdigest()
297
+
298
+
299
+ def scopes_overlap(left, right) -> bool:
300
+ return any(a == b or a.startswith(b + "/") or b.startswith(a + "/") for a in left for b in right)
301
+
302
+
303
+ def capture_files(repo: Path, files: list[str]) -> dict:
304
+ result = {}
305
+ for filename in files:
306
+ safe_file(repo, filename)
307
+ path = repo / filename
308
+ result[filename] = {"data": base64.b64encode(path.read_bytes()).decode(),
309
+ "mode": path.stat().st_mode & 0o777} if path.exists() else None
310
+ return result
311
+
312
+
313
+ def scoped_diff(repo: Path, state: dict, ticket_id: str) -> str:
314
+ valid_id(ticket_id)
315
+ t = state["tickets"][ticket_id]
316
+ path = run_dir(repo, state["run"]) / "snapshots" / f"{ticket_id}.json"
317
+ before = json.loads(path.read_text())
318
+ after = capture_files(repo, t["files"])
319
+ result = []
320
+ for filename in t["files"]:
321
+ old, new = before[filename], after[filename]
322
+ if old == new:
323
+ continue
324
+ old_bytes = base64.b64decode(old["data"]) if old else b""
325
+ new_bytes = base64.b64decode(new["data"]) if new else b""
326
+ result.append(f"diff --fleet {filename}\n")
327
+ result.append(f"before: {digest(old_bytes) if old else 'absent'} mode={old['mode'] if old else '-'}\n")
328
+ result.append(f"after: {digest(new_bytes) if new else 'absent'} mode={new['mode'] if new else '-'}\n")
329
+ try:
330
+ old_text, new_text = old_bytes.decode("utf-8"), new_bytes.decode("utf-8")
331
+ result.extend(difflib.unified_diff(old_text.splitlines(keepends=True), new_text.splitlines(keepends=True),
332
+ fromfile=f"a/{filename}" if old else "/dev/null", tofile=f"b/{filename}" if new else "/dev/null"))
333
+ result.append("\n")
334
+ except UnicodeDecodeError:
335
+ result.append("Binary content changed; inspect artifact separately.\n")
336
+ return "".join(result)
337
+
338
+
339
+ def reconcile(repo: Path, state: dict) -> None:
340
+ for w in state["workers"].values():
341
+ if w["state"] not in ACTIVE_STATES or not w.get("exit_file"):
342
+ continue
343
+ receipt = contained(run_dir(repo, state["run"]), Path(w["exit_file"]))
344
+ started = receipt.with_suffix(".started.json")
345
+ if started.exists():
346
+ info = json.loads(started.read_text())
347
+ if info.get("token") == w.get("token"):
348
+ w["process"] = info
349
+ if receipt.exists():
350
+ data = json.loads(receipt.read_text())
351
+ if data.get("token") == w.get("token"):
352
+ w["state"] = "exited"
353
+ w["returncode"] = data["returncode"]
354
+
355
+
356
+ def assert_scope_idle(state: dict, files: list[str]) -> None:
357
+ for w in active(state):
358
+ owner = w.get("review_of") or w["id"]
359
+ if scopes_overlap(files, state["tickets"][owner]["files"]):
360
+ raise FleetError("scope overlaps a worker whose exit is unconfirmed")
361
+
362
+
363
+ def ensure_other_runs_idle(repo: Path, current: str) -> None:
364
+ root = contained(repo.resolve(), repo.resolve() / ".fleet" / "runs")
365
+ for file in root.glob("*/run.json"):
366
+ contained(root, file)
367
+ if file.parent.name == current:
368
+ continue
369
+ state = json.loads(file.read_text())
370
+ if state.get("schema") == 2:
371
+ reconcile(repo, state)
372
+ if active(state):
373
+ raise FleetError(f"run {file.parent.name} has workers with unconfirmed exits; use one active run per checkout")
374
+
375
+
376
+ def read_evidence(filename: str) -> str:
377
+ text = Path(filename).read_text().strip()
378
+ if not text:
379
+ raise FleetError("evidence file must describe the checks and their results")
380
+ return text
381
+
382
+
383
+ def run_child(args: list[str]) -> int:
384
+ """Runs inside the worker terminal. Reports exit, never writes the shared run state."""
385
+ if len(args) != 3:
386
+ raise FleetError("internal runner expects receipt, token, command")
387
+ path, token, command = args
388
+ child = subprocess.Popen(command, shell=True, executable="/bin/sh")
389
+ atomic_json(Path(path).with_suffix(".started.json"),
390
+ {"token": token, "wrapper_pid": os.getpid(), "child_pid": child.pid})
391
+ while True:
392
+ try:
393
+ code = child.wait()
394
+ break
395
+ except KeyboardInterrupt:
396
+ # Ctrl-C also reaches the child; keep watching until it actually exits.
397
+ continue
398
+ atomic_json(Path(path), {"token": token, "returncode": code})
399
+ return code
400
+
401
+
402
+ # ---------- commands ----------
403
+
404
+ def cmd_init(a, cfg):
405
+ repo = repo_root()
406
+ ensure_other_runs_idle(repo, a.run)
407
+ d = run_dir(repo, a.run)
408
+ if (d / "run.json").exists():
409
+ raise FleetError(f"run already exists; use fleet resume {a.run}")
410
+ workspace = a.workspace or cfg["defaults"]["workspace"] or os.environ.get("CMUX_WORKSPACE_ID")
411
+ if not workspace:
412
+ raise FleetError("no cmux workspace: pass --workspace or run inside cmux")
413
+ cmux("list-panes", "--workspace", workspace) # Fail before creating state for an invalid workspace.
414
+ options = dict(cfg["defaults"], routing=a.routing or cfg["defaults"]["routing"],
415
+ review=a.review or cfg["defaults"]["review"])
416
+ routing_pool(options["routing"], cfg)
417
+ for sub in ("prompts", "reports", ".seen", "exits", "snapshots", "evidence"):
418
+ (d / sub).mkdir(parents=True, exist_ok=True)
419
+ gi = repo / ".fleet" / ".gitignore"
420
+ previous = gi.read_text() if gi.exists() else ""
421
+ if "runs/" not in previous.splitlines():
422
+ gi.write_text(previous + ("\n" if previous and not previous.endswith("\n") else "") + "runs/\n")
423
+ state = {"schema": 2, "run": a.run, "repo": str(repo), "workspace": workspace,
424
+ "created": time.strftime("%Y-%m-%dT%H:%M:%S"), "baseline": git_snapshot(repo),
425
+ "options": options, "tickets": {}, "workers": {}, "prompts": {}, "decisions": []}
426
+ save_run(repo, a.run, state)
427
+ print(f"run dir: {d}\nworkspace: {workspace}\nNext: fleet plan {a.run} --file <plan.json>")
428
+
429
+
430
+ def cmd_plan(a, cfg):
431
+ repo = repo_root()
432
+ state = load_run(repo, a.run)
433
+ if state["workers"]:
434
+ raise FleetError("plan is frozen after the first launch; record decisions in run notes, or start another run after stopping workers")
435
+ data = json.loads(Path(a.file).read_text())
436
+ tickets = {}
437
+ pool = routing_pool(state["options"]["routing"], cfg)
438
+ for raw in data["tickets"]:
439
+ t = dict(raw)
440
+ i = valid_id(t["id"])
441
+ if i in tickets:
442
+ raise FleetError(f"duplicate ticket {i}")
443
+ if t.get("tier", "standard") not in ("heavy", "standard", "light"):
444
+ raise FleetError(f"invalid tier for {i}")
445
+ t["tier"] = t.get("tier", "standard")
446
+ pcfg = provider(cfg, t["provider"])
447
+ if not pcfg.get("enabled", True):
448
+ raise FleetError(f"disabled provider for {i}")
449
+ if pool is not None:
450
+ if t["provider"] not in pool:
451
+ raise FleetError(f"{i}: provider outside selected routing pool")
452
+ if pool[t["provider"]]:
453
+ if t.get("model") and t["model"] != pool[t["provider"]]:
454
+ raise FleetError(f"{i}: model conflicts with pinned routing")
455
+ t["model"] = pool[t["provider"]]
456
+ t["model"], t["effort"] = resolve_model(pcfg, t["tier"], t.get("model"), t.get("effort"))
457
+ t["family"] = model_family(pcfg, t["tier"], t["model"], t.get("family"))
458
+ ticket = Path(t["ticket"])
459
+ if not ticket.is_absolute():
460
+ ticket = repo / ticket
461
+ if not ticket.is_file():
462
+ raise FleetError(f"missing local ticket: {ticket}; export remote issues first")
463
+ t["ticket"] = str(ticket.resolve())
464
+ if not t.get("files") or not isinstance(t["files"], list):
465
+ raise FleetError(f"{i}: files must be a nonempty list of exact repo-relative paths")
466
+ t["files"] = sorted(set(safe_file(repo, f) for f in t["files"]))
467
+ t["blockers"] = t.get("blockers", [])
468
+ if not isinstance(t["blockers"], list):
469
+ raise FleetError(f"{i}: blockers must be a list")
470
+ t["status"] = "pending"
471
+ t["review_required"] = state["options"]["review"] == "cross-all" or (state["options"]["review"] == "cross-heavy" and t["tier"] == "heavy")
472
+ tickets[i] = t
473
+ if not tickets:
474
+ raise FleetError("plan must contain at least one ticket")
475
+ visited, visiting = set(), set()
476
+ def visit(i):
477
+ if i not in tickets:
478
+ raise FleetError(f"unknown blocker {i}")
479
+ if i in visiting:
480
+ raise FleetError(f"dependency cycle at {i}")
481
+ if i in visited:
482
+ return
483
+ visiting.add(i)
484
+ for dep in tickets[i]["blockers"]:
485
+ visit(dep)
486
+ visiting.remove(i)
487
+ visited.add(i)
488
+ for i in tickets:
489
+ visit(i)
490
+ state["tickets"] = tickets
491
+ state["decisions"] = data.get("decisions", [])
492
+ save_run(repo, a.run, state)
493
+ print(f"saved {len(tickets)} tickets; routing, dependencies and assignments persist across sessions")
494
+
495
+
496
+ def cmd_prompt(a, cfg):
497
+ repo = repo_root()
498
+ state = load_run(repo, a.run)
499
+ valid_id(a.id)
500
+ d = run_dir(repo, a.run)
501
+ out = contained(d, d / "prompts" / f"{a.id}.md")
502
+ if out.exists() and not a.force:
503
+ raise FleetError(f"{out} exists (use --force to overwrite)")
504
+ if a.id in state["workers"] and state["workers"][a.id]["state"] in ACTIVE_STATES:
505
+ raise FleetError("cannot replace a live worker prompt")
506
+ review_of = a.review_of
507
+ if review_of:
508
+ writer = worker(state, review_of)
509
+ if writer.get("role") != "implementation" or writer["state"] in ACTIVE_STATES:
510
+ raise FleetError("stop the implementation worker before preparing review")
511
+ if a.id in state["tickets"]:
512
+ raise FleetError("reviewer must have a separate ID")
513
+ t = state["tickets"][review_of]
514
+ assert_scope_idle(state, t["files"])
515
+ if not a.diff:
516
+ raise FleetError("review prompt needs --diff from fleet diff")
517
+ diff = Path(a.diff).read_text()
518
+ if diff != scoped_diff(repo, state, review_of):
519
+ raise FleetError("review diff differs from current scoped changes; regenerate it")
520
+ snapshot = d / "snapshots" / f"{a.id}.diff"
521
+ snapshot.write_text(diff)
522
+ meta = {"role": "review", "review_of": review_of, "diff_sha": digest(diff.encode())}
523
+ template = "review-prompt.md"
524
+ else:
525
+ if a.id not in state["tickets"]:
526
+ raise FleetError("save the ticket in fleet plan first")
527
+ t = state["tickets"][a.id]
528
+ meta = {"role": "implementation"}
529
+ template = "ticket-prompt.md"
530
+ ticket = a.ticket or t["ticket"]
531
+ if str(Path(ticket).resolve()) != t["ticket"]:
532
+ raise FleetError("ticket differs from saved plan")
533
+ fields = {"id": a.id, "ticket": t["ticket"], "report": str(d / "reports" / f"{a.id}.md"),
534
+ "repo": str(repo), "files": ", ".join(t["files"]),
535
+ "diff": str(d / "snapshots" / f"{a.id}.diff") if review_of else "",
536
+ "review_of": review_of or ""}
537
+ parts = [] if review_of else [(SKILL_DIR / "templates" / "preamble.md").read_text()]
538
+ rules = repo / ".fleet" / "rules.md"
539
+ if rules.exists():
540
+ parts.append("## Repo rules\n\n" + rules.read_text())
541
+ parts.append((SKILL_DIR / "templates" / template).read_text())
542
+ parts.append("## Report format\n\n" + (SKILL_DIR / "templates" / "report.md").read_text())
543
+ text = "\n\n".join(p.strip() for p in parts) + "\n"
544
+ for k, v in fields.items():
545
+ text = text.replace("{{" + k + "}}", v)
546
+ out.write_text(text)
547
+ state["prompts"][a.id] = meta
548
+ save_run(repo, a.run, state)
549
+ print(out)
550
+
551
+
552
+ def cmd_launch(a, cfg):
553
+ repo = repo_root()
554
+ ensure_other_runs_idle(repo, a.run)
555
+ state = load_run(repo, a.run)
556
+ valid_id(a.id)
557
+ reconcile(repo, state)
558
+ save_run(repo, a.run, state)
559
+ pcfg = provider(cfg, a.provider)
560
+ if not pcfg.get("enabled", True):
561
+ raise FleetError(f"provider '{a.provider}' is turned off in config")
562
+ prompt_path = contained(run_dir(repo, a.run), run_dir(repo, a.run) / "prompts" / f"{a.id}.md")
563
+ if not prompt_path.exists() or a.id not in state["prompts"]:
564
+ raise FleetError("write the prompt first with fleet prompt")
565
+ if "<!-- LEAD:" in prompt_path.read_text() or "{{" in prompt_path.read_text():
566
+ raise FleetError("fill all LEAD fields/placeholders in the prompt before launch")
567
+ w = state["workers"].get(a.id)
568
+ if w and w.get("state") in ACTIVE_STATES:
569
+ raise FleetError(f"worker {a.id} may still be running on {w['surface']}; stop or recover it first")
570
+ running = active(state)
571
+ if len(running) >= state["options"]["max_parallel"]:
572
+ raise FleetError("max_parallel reached; stop and confirm an existing worker's exit first")
573
+ if sum(x["provider"] == a.provider for x in running) >= pcfg.get("max", 2):
574
+ raise FleetError(f"{a.provider} is at capacity; stop and confirm a worker's exit first")
575
+ meta = state["prompts"][a.id]
576
+ if meta["role"] == "implementation":
577
+ t = state["tickets"][a.id]
578
+ if t["status"] == "verified":
579
+ raise FleetError("ticket already verified; create a follow-up ticket")
580
+ if any(state["tickets"][b]["status"] != "verified" for b in t["blockers"]):
581
+ raise FleetError("ticket has unverified blockers")
582
+ model, effort = resolve_model(pcfg, a.tier, a.model, a.effort)
583
+ if (a.provider, a.tier, model, effort) != (t["provider"], t["tier"], t["model"], t["effort"]):
584
+ raise FleetError("launch provider/tier/model/effort must match saved plan")
585
+ family = model_family(pcfg, a.tier, model, a.family)
586
+ if family != t["family"]:
587
+ raise FleetError("launch family must match saved plan")
588
+ for other in running:
589
+ owned = state["tickets"][other.get("review_of") or other["id"]]["files"]
590
+ if scopes_overlap(t["files"], owned):
591
+ raise FleetError("files overlap an active implementation/review; wait for its exit")
592
+ else:
593
+ if a.tier != "review":
594
+ raise FleetError("review prompt requires --tier review")
595
+ t = state["tickets"][meta["review_of"]]
596
+ if worker(state, meta["review_of"])["state"] in ACTIVE_STATES:
597
+ raise FleetError("stop writer before review")
598
+ model, effort = resolve_model(pcfg, "review", a.model, a.effort)
599
+ family = model_family(pcfg, "review", model, a.family)
600
+ if not family or not t["family"] or family == t["family"]:
601
+ raise FleetError("review requires known different model families; configure family or pass --family")
602
+ pool = routing_pool(state["options"]["routing"], cfg)
603
+ if pool is not None and (a.provider not in pool or (pool[a.provider] and model != pool[a.provider])):
604
+ raise FleetError("reviewer outside pinned routing pool; cross-family review cannot proceed")
605
+ assert_scope_idle(state, t["files"])
606
+ if digest(scoped_diff(repo, state, meta["review_of"]).encode()) != meta["diff_sha"]:
607
+ raise FleetError("review snapshot is stale; regenerate diff and prompt")
608
+ d = run_dir(repo, a.run)
609
+ baseline = d / "snapshots" / f"{a.id}.json"
610
+ if meta["role"] == "implementation" and not baseline.exists():
611
+ atomic_json(baseline, capture_files(repo, t["files"]))
612
+ report = d / "reports" / f"{a.id}.md"
613
+ if report.exists():
614
+ report.replace(d / "reports" / f"{a.id}.{uuid.uuid4().hex}.previous")
615
+ token = uuid.uuid4().hex
616
+ command = build_launch(pcfg, model, effort, worker_prompt_line(prompt_path, a.id))
617
+ exit_file = d / "exits" / f"{a.id}.{token}.json"
618
+ wrapped = shlex.join([sys.executable, str(Path(__file__).resolve()), "_run", str(exit_file), token, command])
619
+ # Persist before launch: an uncertain cmux result must not silently free capacity.
620
+ state["workers"][a.id] = dict(meta, id=a.id, provider=a.provider, model=model, effort=effort,
621
+ family=family, tier=a.tier, surface="unknown", state="launching", command=command,
622
+ token=token, exit_file=str(exit_file), launched=time.strftime("%Y-%m-%dT%H:%M:%S"))
623
+ save_run(repo, a.run, state)
624
+ args = ["new-surface", "--workspace", state["workspace"], "--working-directory", str(repo),
625
+ "--command", wrapped, "--focus", "false"]
626
+ if a.pane:
627
+ args += ["--pane", a.pane]
628
+ out = cmux(*args)
629
+ m = SURFACE_RE.search(out)
630
+ if not m:
631
+ raise FleetError(f"uncertain launch; inspect cmux before recovery: {out.strip()}")
632
+ w = state["workers"][a.id]
633
+ w["surface"], w["state"] = m.group(0), "running"
634
+ save_run(repo, a.run, state)
635
+ label = f"{a.id} · {a.provider}:{model}" + (f"@{effort}" if effort else "")
636
+ sh(["cmux", "rename-tab", "--workspace", state["workspace"], "--surface", w["surface"], label], check=False)
637
+ print(f"{a.id}: {label} on {w['surface']}")
638
+
639
+
640
+ def send_keys(workspace: str, surface: str, items: list[str]) -> None:
641
+ for item in items:
642
+ if item in ("enter", "ctrl+c", "escape", "tab"):
643
+ cmux("send-key", "--workspace", workspace, "--surface", surface, item)
644
+ else:
645
+ cmux("send", "--workspace", workspace, "--surface", surface, "--", item)
646
+ time.sleep(0.4)
647
+
648
+
649
+ def cmd_send(a, cfg):
650
+ repo = repo_root()
651
+ state = load_run(repo, a.run)
652
+ w = worker(state, a.id)
653
+ send_keys(state["workspace"], w["surface"], [" ".join(a.text), "enter"])
654
+ print(f"sent to {a.id} ({w['surface']})")
655
+
656
+
657
+ def cmd_peek(a, cfg):
658
+ repo = repo_root()
659
+ state = load_run(repo, a.run)
660
+ w = worker(state, a.id)
661
+ print(cmux("read-screen", "--workspace", state["workspace"], "--surface", w["surface"], "--lines", str(a.lines)))
662
+
663
+
664
+ def report_rows(repo: Path, state: dict) -> list[dict]:
665
+ d = run_dir(repo, state["run"]) / "reports"
666
+ ids = set(state["workers"]) | {p.stem for p in d.glob("*.md")}
667
+ rows = []
668
+ for i in sorted(ids):
669
+ w = state["workers"].get(i, {})
670
+ f = d / f"{i}.md"
671
+ status = parse_status(f.read_text()) if f.exists() else None
672
+ age = int(time.time() - f.stat().st_mtime) if f.exists() else None
673
+ rows.append({"id": i, "worker": f"{w.get('provider', '?')}:{w.get('model', '?')}",
674
+ "surface": w.get("surface", "-"), "state": w.get("state", "-"),
675
+ "report": status or ("-" if not f.exists() else "no status line"),
676
+ "report_age_s": age, "process": w.get("process")})
677
+ return rows
678
+
679
+
680
+ def cmd_status(a, cfg):
681
+ repo = repo_root()
682
+ state = load_run(repo, a.run)
683
+ reconcile(repo, state)
684
+ save_run(repo, a.run, state)
685
+ rows = report_rows(repo, state)
686
+ if a.json:
687
+ print(json.dumps(rows, indent=2))
688
+ return
689
+ print(f"run {state['run']} workspace {state['workspace']} running {len(active(state))}/{cfg['defaults']['max_parallel']}")
690
+ for r in rows:
691
+ age = f"{r['report_age_s'] // 60}m ago" if r["report_age_s"] is not None else ""
692
+ print(f" {r['id']:<10} {r['worker']:<34} {r['surface']:<12} {r['state']:<9} {r['report']} {age}")
693
+
694
+
695
+ WAKE_STATUSES = ("needs-verification", "blocked")
696
+
697
+
698
+ def scan_reports(reports: Path, seen: Path, statuses: tuple[str, ...] | None) -> list[tuple[str, str, Path]]:
699
+ """Reports that reached a wake status since last seen. statuses=None wakes on any change.
700
+
701
+ The lead parks a report by rewriting its Status (verified, changes-requested); a worker
702
+ flipping it back to needs-verification wakes the lead again.
703
+ """
704
+ fresh = []
705
+ for f in sorted(reports.glob("*.md")):
706
+ status = parse_status(f.read_text()) or "no status line"
707
+ if statuses is not None and status.lower() not in statuses:
708
+ continue
709
+ key = f"{status}|{f.stat().st_mtime_ns}"
710
+ marker = seen / f.stem
711
+ if not marker.exists() or marker.read_text() != key:
712
+ marker.write_text(key)
713
+ fresh.append((f.stem, status, f))
714
+ return fresh
715
+
716
+
717
+ def cmd_wait(a, cfg):
718
+ repo = repo_root()
719
+ load_run(repo, a.run)
720
+ d = run_dir(repo, a.run)
721
+ seen = d / ".seen"
722
+ seen.mkdir(exist_ok=True)
723
+ statuses = None if a.any_change else WAKE_STATUSES
724
+ deadline = time.time() + a.timeout
725
+ while True:
726
+ fresh = scan_reports(d / "reports", seen, statuses)
727
+ if fresh:
728
+ for i, status, f in fresh:
729
+ print(f"REPORT {i} [{status}] {f}")
730
+ return 0
731
+ if time.time() >= deadline:
732
+ print(f"TIMEOUT no new report in {a.timeout}s; check panes: fleet status {a.run}")
733
+ return 2
734
+ time.sleep(min(a.interval, max(0, deadline - time.time())))
735
+
736
+
737
+ def cmd_stop(a, cfg):
738
+ repo = repo_root()
739
+ state = load_run(repo, a.run)
740
+ reconcile(repo, state)
741
+ ids = list(state["workers"]) if a.all else a.ids
742
+ if not ids and not a.all:
743
+ raise FleetError("name workers to stop, or pass --all")
744
+ failed = False
745
+ for i in ids:
746
+ w = worker(state, i)
747
+ try:
748
+ if w["state"] in ACTIVE_STATES:
749
+ send_keys(state["workspace"], w["surface"], provider(cfg, w["provider"]).get("quit", ["/exit", "enter"]))
750
+ deadline = time.monotonic() + a.timeout
751
+ while w["state"] in ACTIVE_STATES and time.monotonic() < deadline:
752
+ reconcile(repo, state)
753
+ if w["state"] in ACTIVE_STATES:
754
+ time.sleep(0.2)
755
+ if w["state"] in ACTIVE_STATES:
756
+ raise FleetError("quit sent but process exit unconfirmed; slot retained")
757
+ if a.close and w["state"] != "closed":
758
+ cmux("close-surface", "--workspace", state["workspace"], "--surface", w["surface"])
759
+ w["state"] = "closed"
760
+ except (FleetError, OSError, subprocess.TimeoutExpired) as e:
761
+ failed = True
762
+ if w["state"] in ACTIVE_STATES:
763
+ w["state"] = "stop-failed"
764
+ print(f"{i}: {e}", file=sys.stderr)
765
+ save_run(repo, a.run, state)
766
+ print(f"{i}: {w['state']} ({w['surface']})")
767
+ return 1 if failed else 0
768
+
769
+
770
+ def cmd_resume(a, cfg):
771
+ repo = repo_root()
772
+ state = load_run(repo, a.run)
773
+ reconcile(repo, state)
774
+ save_run(repo, a.run, state)
775
+ print(json.dumps({"run": state["run"], "workspace": state["workspace"], "options": state["options"],
776
+ "tickets": state["tickets"], "decisions": state["decisions"],
777
+ "workers": report_rows(repo, state)}, indent=2))
778
+ print("Unconfirmed workers retain their slots. Inspect panes/processes before stop or recover.")
779
+
780
+
781
+ def cmd_recover(a, cfg):
782
+ repo = repo_root()
783
+ state = load_run(repo, a.run)
784
+ w = worker(state, a.id)
785
+ evidence = read_evidence(a.evidence)
786
+ # Deliberate lead attestation for externally stopped/legacy/crashed processes.
787
+ w["state"] = "recovered"
788
+ w["recovery_evidence"] = evidence
789
+ save_run(repo, a.run, state)
790
+ print("Recorded lead-confirmed process exit; ticket acceptance is unchanged.")
791
+
792
+
793
+ def cmd_diff(a, cfg):
794
+ repo = repo_root()
795
+ state = load_run(repo, a.run)
796
+ reconcile(repo, state)
797
+ save_run(repo, a.run, state)
798
+ w = worker(state, a.id)
799
+ if w["state"] in ACTIVE_STATES:
800
+ raise FleetError("stop writer before capturing a review diff")
801
+ if w.get("role") != "implementation":
802
+ raise FleetError("diff expects an implementation ID")
803
+ assert_scope_idle(state, state["tickets"][a.id]["files"])
804
+ result = scoped_diff(repo, state, a.id)
805
+ if a.output:
806
+ Path(a.output).write_text(result)
807
+ else:
808
+ print(result, end="")
809
+
810
+
811
+ def cmd_verify(a, cfg):
812
+ repo = repo_root()
813
+ state = load_run(repo, a.run)
814
+ reconcile(repo, state)
815
+ save_run(repo, a.run, state)
816
+ w = worker(state, a.id)
817
+ if w["state"] in ACTIVE_STATES:
818
+ raise FleetError("stop worker and confirm exit before accepting its result")
819
+ owner = w.get("review_of") or a.id
820
+ assert_scope_idle(state, state["tickets"][owner]["files"])
821
+ evidence = read_evidence(a.evidence)
822
+ report = run_dir(repo, a.run) / "reports" / f"{a.id}.md"
823
+ if not report.exists() or parse_status(report.read_text()) != "needs-verification":
824
+ raise FleetError("report must have Status: needs-verification")
825
+ if w["role"] == "implementation":
826
+ t = state["tickets"][a.id]
827
+ if t["review_required"]:
828
+ sha = digest(scoped_diff(repo, state, a.id).encode())
829
+ if not any(r.get("review_of") == a.id and r.get("verified") and r.get("diff_sha") == sha for r in state["workers"].values()):
830
+ raise FleetError("required cross-family review is missing, unverified or stale")
831
+ t["status"] = "verified"
832
+ else:
833
+ sha = digest(scoped_diff(repo, state, w["review_of"]).encode())
834
+ if sha != w["diff_sha"]:
835
+ raise FleetError("review is stale; regenerate diff and review")
836
+ w["verified"] = True
837
+ w["verification_evidence"] = evidence
838
+ text = report.read_text()
839
+ lines = text.splitlines()
840
+ for n, line in enumerate(lines):
841
+ if STATUS_RE.match(line):
842
+ lines[n] = "Status: verified"
843
+ break
844
+ report.write_text("\n".join(lines) + "\n\n## Lead verification\n" + evidence + "\n")
845
+ save_run(repo, a.run, state)
846
+ print(f"{a.id}: verified; update the source ticket only after implementation acceptance")
847
+
848
+
849
+ def cmd_check(a, cfg):
850
+ repo = repo_root()
851
+ state = load_run(repo, a.run)
852
+ problems = compare_snapshots(state["baseline"], git_snapshot(repo))
853
+ if problems:
854
+ print("CHANGED since the run started (fleet never commits or stages; find out who did):")
855
+ for p in problems:
856
+ print(f" - {p}")
857
+ return 1
858
+ print("OK: captured HEAD and index states match the run baseline")
859
+ return 0
860
+
861
+
862
+ def first_binary(bin_field: str) -> str:
863
+ tokens = [t for t in shlex.split(bin_field) if "=" not in t or t.startswith("-")]
864
+ return tokens[0] if tokens else bin_field
865
+
866
+
867
+ def cmd_doctor(a, cfg):
868
+ ok = True
869
+ try:
870
+ cmux("ping", timeout=5)
871
+ print("cmux ready")
872
+ except (FleetError, FileNotFoundError, subprocess.TimeoutExpired) as e:
873
+ ok = False
874
+ print(f"cmux NOT REACHABLE: {e}")
875
+ if os.environ.get("CODEX_SANDBOX") or os.environ.get("CODEX_SANDBOX_NETWORK_DISABLED"):
876
+ print(" Check host socket permissions; do not bypass a denied permission through another harness.")
877
+ print(f"config {USER_CONFIG if USER_CONFIG.exists() else 'none (built-in defaults)'}")
878
+ names = a.providers or list(cfg["providers"])
879
+ for name in names:
880
+ p = provider(cfg, name)
881
+ if not p.get("enabled", True):
882
+ print(f"{name:<13} off")
883
+ continue
884
+ binary = first_binary(p["bin"])
885
+ if not shutil.which(binary):
886
+ ok = False
887
+ print(f"{name:<13} MISSING binary '{binary}'")
888
+ continue
889
+ if "login" not in p:
890
+ print(f"{name:<13} installed (no login check defined)")
891
+ continue
892
+ try:
893
+ res = subprocess.run(p["login"], shell=True, capture_output=True, text=True, timeout=60)
894
+ ready = res.returncode == 0 and bool(p.get("login_ok")) and p["login_ok"] in res.stdout + res.stderr # codex prints its status to stderr
895
+ except subprocess.TimeoutExpired:
896
+ ready = False
897
+ ok &= ready
898
+ tiers = ", ".join(f"{t}={v.get('model')}" for t, v in p.get("tiers", {}).items())
899
+ print(f"{name:<13} {'authenticated (models unverified)' if ready else 'LOGIN CHECK FAILED'} max={p.get('max', 2)} {tiers}")
900
+ for harness, d in HARNESS_SKILL_DIRS.items():
901
+ target = Path(d).expanduser() / "fleet"
902
+ print(f"skill@{harness:<7} {'installed' if (target / 'SKILL.md').exists() else 'not installed'}")
903
+ return 0 if ok else 1
904
+
905
+
906
+ def cmd_providers(a, cfg):
907
+ print(json.dumps({"defaults": cfg["defaults"], "providers": cfg["providers"]}, indent=2))
908
+
909
+
910
+ def main(argv: list[str] | None = None) -> int:
911
+ argv = sys.argv[1:] if argv is None else argv
912
+ if argv and argv[0] == "_run":
913
+ return run_child(argv[1:])
914
+ ap = argparse.ArgumentParser(prog="fleet", description=__doc__.splitlines()[0])
915
+ sub = ap.add_subparsers(dest="cmd", required=True)
916
+
917
+ s = sub.add_parser("doctor", help="check cmux, provider logins and skill installs")
918
+ s.add_argument("providers", nargs="*")
919
+ s.set_defaults(fn=cmd_doctor)
920
+
921
+ s = sub.add_parser("providers", help="print the merged provider config")
922
+ s.set_defaults(fn=cmd_providers)
923
+
924
+ s = sub.add_parser("init", help="start a run: dirs + git baseline")
925
+ s.add_argument("run")
926
+ s.add_argument("--workspace")
927
+ s.add_argument("--routing")
928
+ s.add_argument("--review", choices=["off", "cross-heavy", "cross-all"])
929
+ s.set_defaults(fn=cmd_init)
930
+
931
+ s = sub.add_parser("plan", help="persist a ticket graph and routing assignments")
932
+ s.add_argument("run")
933
+ s.add_argument("--file", required=True)
934
+ s.set_defaults(fn=cmd_plan)
935
+
936
+ s = sub.add_parser("resume", help="reconcile exits and show saved plan and decisions")
937
+ s.add_argument("run")
938
+ s.set_defaults(fn=cmd_resume)
939
+
940
+ for name, fn in (("verify", cmd_verify), ("recover", cmd_recover)):
941
+ s = sub.add_parser(name, help="record lead verification" if name == "verify" else "attest worker process has exited after manual inspection")
942
+ s.add_argument("run")
943
+ s.add_argument("id")
944
+ s.add_argument("--evidence", required=True)
945
+ s.set_defaults(fn=fn)
946
+
947
+ s = sub.add_parser("diff", help="diff owned files against their launch baseline")
948
+ s.add_argument("run")
949
+ s.add_argument("id")
950
+ s.add_argument("--output")
951
+ s.set_defaults(fn=cmd_diff)
952
+
953
+ s = sub.add_parser("prompt", help="scaffold a worker prompt from the templates")
954
+ s.add_argument("run")
955
+ s.add_argument("id")
956
+ s.add_argument("--ticket")
957
+ s.add_argument("--review-of")
958
+ s.add_argument("--diff")
959
+ s.add_argument("--force", action="store_true")
960
+ s.set_defaults(fn=cmd_prompt)
961
+
962
+ s = sub.add_parser("launch", help="launch a worker in a new cmux tab")
963
+ s.add_argument("run")
964
+ s.add_argument("id")
965
+ s.add_argument("provider")
966
+ s.add_argument("--tier", choices=["heavy", "standard", "light", "review"], default="standard")
967
+ s.add_argument("--model")
968
+ s.add_argument("--effort")
969
+ s.add_argument("--family", help="known family for an account-specific model override")
970
+ s.add_argument("--pane")
971
+ s.set_defaults(fn=cmd_launch)
972
+
973
+ s = sub.add_parser("send", help="type a message into a worker's pane and press enter")
974
+ s.add_argument("run")
975
+ s.add_argument("id")
976
+ s.add_argument("text", nargs="+")
977
+ s.set_defaults(fn=cmd_send)
978
+
979
+ s = sub.add_parser("peek", help="read the bottom of a worker's screen")
980
+ s.add_argument("run")
981
+ s.add_argument("id")
982
+ s.add_argument("--lines", type=int, default=40)
983
+ s.set_defaults(fn=cmd_peek)
984
+
985
+ s = sub.add_parser("status", help="workers and their report status")
986
+ s.add_argument("run")
987
+ s.add_argument("--json", action="store_true")
988
+ s.set_defaults(fn=cmd_status)
989
+
990
+ s = sub.add_parser("wait", help="block until a report reaches needs-verification or blocked (exit 0), "
991
+ "or timeout (exit 2)")
992
+ s.add_argument("run")
993
+ s.add_argument("--timeout", type=int, default=45)
994
+ s.add_argument("--interval", type=int, default=10)
995
+ s.add_argument("--any-change", action="store_true", help="wake on any report change, whatever its status")
996
+ s.set_defaults(fn=cmd_wait)
997
+
998
+ s = sub.add_parser("stop", help="send each worker its quit sequence")
999
+ s.add_argument("run")
1000
+ s.add_argument("ids", nargs="*")
1001
+ s.add_argument("--all", action="store_true")
1002
+ s.add_argument("--close", action="store_true", help="close tab only after confirmed process exit")
1003
+ s.add_argument("--timeout", type=float, default=10)
1004
+ s.set_defaults(fn=cmd_stop)
1005
+
1006
+ s = sub.add_parser("check", help="compare HEAD and index with the captured baseline")
1007
+ s.add_argument("run")
1008
+ s.set_defaults(fn=cmd_check)
1009
+
1010
+ a = ap.parse_args(argv)
1011
+ try:
1012
+ if hasattr(a, "id"):
1013
+ valid_id(a.id)
1014
+ if hasattr(a, "run"):
1015
+ valid_id(a.run)
1016
+ if hasattr(a, "timeout") and not 0 <= a.timeout <= 60:
1017
+ raise FleetError("timeout must be between 0 and 60 seconds")
1018
+ if hasattr(a, "interval") and not 0 < a.interval <= 60:
1019
+ raise FleetError("interval must be between 0 and 60 seconds")
1020
+ with mutation_lock(a):
1021
+ return a.fn(a, load_config()) or 0
1022
+ except (FleetError, OSError, ValueError, KeyError, TypeError, subprocess.TimeoutExpired) as e:
1023
+ print(f"fleet: {e}", file=sys.stderr)
1024
+ return 1
1025
+
1026
+
1027
+ if __name__ == "__main__":
1028
+ sys.exit(main())