caokit 0.3.0.dev0__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.
Files changed (46) hide show
  1. caokit/__init__.py +3 -0
  2. caokit/adapters.py +259 -0
  3. caokit/cli.py +704 -0
  4. caokit/company.py +115 -0
  5. caokit/config.py +191 -0
  6. caokit/data/adapters/antigravity.toml +35 -0
  7. caokit/data/adapters/claude.toml +38 -0
  8. caokit/data/adapters/codex.toml +33 -0
  9. caokit/data/roles/engineer.md +90 -0
  10. caokit/data/roles/general.md +177 -0
  11. caokit/data/roles/inspector.md +55 -0
  12. caokit/data/roles/scout.md +77 -0
  13. caokit/data/scaffold/company/BACKLOG.md +5 -0
  14. caokit/data/scaffold/company/COMPANY.md +28 -0
  15. caokit/data/scaffold/company/DECISIONS.md +5 -0
  16. caokit/data/scaffold/company/README.md +16 -0
  17. caokit/data/scaffold/company/TEAM.md +7 -0
  18. caokit/data/scaffold/company/contracts/ARTIFACT_CONTRACT.md +44 -0
  19. caokit/data/scaffold/company/contracts/COMMUNICATION_CONTRACT.md +38 -0
  20. caokit/data/scaffold/company/contracts/COMPANY_CONTRACT.md +42 -0
  21. caokit/data/scaffold/company/contracts/REVIEW_CONTRACT.md +36 -0
  22. caokit/data/scaffold/company/contracts/SAFETY_CONTRACT.md +40 -0
  23. caokit/data/scaffold/company/contracts/STATE_CONTRACT.md +31 -0
  24. caokit/data/scaffold/company/contracts/TASK_CONTRACT.md +26 -0
  25. caokit/data/scaffold/company/prompts/first-run.md +18 -0
  26. caokit/data/scaffold/company/tasks/TASK-0001-smoke-test.md +109 -0
  27. caokit/data/scaffold/company/tasks/TASK_TEMPLATE.md +73 -0
  28. caokit/doctor.py +287 -0
  29. caokit/gitignore.py +36 -0
  30. caokit/init_project.py +183 -0
  31. caokit/install.py +242 -0
  32. caokit/launch.py +102 -0
  33. caokit/project.py +359 -0
  34. caokit/render.py +129 -0
  35. caokit/reset.py +208 -0
  36. caokit/roster.py +95 -0
  37. caokit/schedule.py +104 -0
  38. caokit/stack.py +117 -0
  39. caokit/tasks.py +180 -0
  40. caokit/worktrees.py +107 -0
  41. caokit-0.3.0.dev0.dist-info/METADATA +382 -0
  42. caokit-0.3.0.dev0.dist-info/RECORD +46 -0
  43. caokit-0.3.0.dev0.dist-info/WHEEL +5 -0
  44. caokit-0.3.0.dev0.dist-info/entry_points.txt +2 -0
  45. caokit-0.3.0.dev0.dist-info/licenses/LICENSE +21 -0
  46. caokit-0.3.0.dev0.dist-info/top_level.txt +1 -0
caokit/init_project.py ADDED
@@ -0,0 +1,183 @@
1
+ """`caokit init <repo>`: scaffold .company/, write ROSTER.md and merge .gitignore."""
2
+
3
+ from collections.abc import Iterator
4
+ from dataclasses import dataclass, field
5
+ from importlib import resources
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from .adapters import ROLES, Runner, default_runner, load_adapter
10
+ from .config import Config
11
+ from .gitignore import BEGIN, merge_gitignore
12
+ from .project import PROJECT_FILE, Overrides, dump_overrides
13
+ from .render import profile_name
14
+ from .stack import Suggestion, suggest_validation
15
+
16
+ if TYPE_CHECKING: # typing-only: importlib.abc.Traversable is not importable at runtime on 3.14+
17
+ from importlib.abc import Traversable
18
+
19
+ # Directories agents write into. Created even though git does not track empty
20
+ # directories, because the whole of .company/ except contracts/ is ignored anyway.
21
+ EMPTY_DIRS = ["artifacts", "decisions", "handoffs", "research", "reviews", "tasks"]
22
+
23
+
24
+ class InitError(Exception):
25
+ """Raised when a project cannot be initialised (not a git repo, bad path)."""
26
+
27
+
28
+ @dataclass
29
+ class InitResult:
30
+ """What ``init_project`` did, for the CLI to report."""
31
+
32
+ created: list[str] = field(default_factory=list) # scaffold files newly written
33
+ kept: list[str] = field(default_factory=list) # existing files left untouched
34
+ warnings: list[str] = field(default_factory=list)
35
+ suggestion: Suggestion | None = None # detected validation command, if any
36
+
37
+
38
+ def render_roster_md(cfg: Config, suggestion: Suggestion | None) -> str:
39
+ """Render ``.company/ROSTER.md``, the file the General reads to dispatch.
40
+
41
+ Args:
42
+ cfg: The company configuration; one table row per role.
43
+ suggestion: Detected validation command, or None when nothing was detected.
44
+
45
+ Returns:
46
+ The Markdown text, ending in a newline.
47
+ """
48
+ rows = []
49
+ for role in ROLES:
50
+ b = cfg.roles[role]
51
+ a = load_adapter(b.tool)
52
+ model = b.model or "(tool default)"
53
+ rows.append(
54
+ f"| {role} | `{profile_name(role, b.tool, cfg.project)}` | {b.tool} | {model} "
55
+ f"| {a.enforcement} |"
56
+ )
57
+ lines = [
58
+ "# Roster",
59
+ "",
60
+ f"Generated by `caokit init` for project `{cfg.project}` from the user-level "
61
+ "`caokit.toml` plus `.company/caokit.toml` overrides. Regenerate; do not hand-edit."
62
+ if cfg.project
63
+ else "Generated by `caokit init` from `caokit.toml`. Regenerate; do not hand-edit.",
64
+ "",
65
+ "Dispatch **only** to the profiles listed here, using the name verbatim. "
66
+ "Never guess a profile name.",
67
+ "",
68
+ "| Role | CAO profile | Tool | Model | Enforcement |",
69
+ "|---|---|---|---|---|",
70
+ *rows,
71
+ "",
72
+ "`soft` enforcement means the tool's limits are advisory only.",
73
+ "",
74
+ "## Suggested validation command (unconfirmed)",
75
+ "",
76
+ ]
77
+ if suggestion is None:
78
+ lines.append(
79
+ "None detected. The human must supply the authorised validation command "
80
+ "at task approval."
81
+ )
82
+ else:
83
+ if suggestion.command: # CI-only hints carry a reason but no command
84
+ lines += [f"`{suggestion.command}`", ""]
85
+ lines.append(f"Detected because: {suggestion.reason}.")
86
+ if suggestion.note:
87
+ lines += ["", f"Note: {suggestion.note}"]
88
+ lines += [
89
+ "",
90
+ "The human must confirm this at task approval; "
91
+ "never treat it as authorised until then.",
92
+ ]
93
+ return "\n".join(lines) + "\n"
94
+
95
+
96
+ def _walk(node: "Traversable", rel: str = "") -> Iterator[tuple[str, "Traversable"]]:
97
+ """Yield ``(relative/posix/path, file)`` for every file under ``node``, sorted."""
98
+ for child in sorted(node.iterdir(), key=lambda p: p.name):
99
+ r = rel + child.name
100
+ if child.is_dir():
101
+ yield from _walk(child, r + "/")
102
+ else:
103
+ yield r, child
104
+
105
+
106
+ def _ignores_whole_company(text: str) -> bool:
107
+ """Return True if the user's own rules ignore all of ``.company/``.
108
+
109
+ Only text before our managed block counts; our block deliberately ignores
110
+ ``.company/*`` while re-including ``contracts/``.
111
+ """
112
+ before = text.split(BEGIN)[0]
113
+ return any(
114
+ line.strip() in (".company", ".company/", "/.company", "/.company/")
115
+ for line in before.splitlines()
116
+ )
117
+
118
+
119
+ def init_project(
120
+ repo: Path,
121
+ cfg: Config,
122
+ runner: Runner = default_runner,
123
+ overrides: Overrides | None = None,
124
+ force_project: bool = False,
125
+ ) -> InitResult:
126
+ """Scaffold ``.company/`` in a git repo, write ROSTER.md and merge .gitignore.
127
+
128
+ Existing scaffold files are never overwritten, so re-running is safe.
129
+ ROSTER.md is the exception: it is generated output and is rewritten.
130
+
131
+ Args:
132
+ repo: Root of the target git repository.
133
+ cfg: The company configuration used to render ROSTER.md.
134
+ runner: Command runner used by stack detection (injectable for tests).
135
+ overrides: New project overrides to save as ``.company/caokit.toml``, or None
136
+ when the project file already exists or the project is not namespaced.
137
+ force_project: Overwrite an existing ``.company/caokit.toml``.
138
+
139
+ Returns:
140
+ A summary of created and kept files, warnings and the detected
141
+ validation command.
142
+
143
+ Raises:
144
+ InitError: ``repo`` is not a directory or has no ``.git``, or a project file
145
+ exists and ``force_project`` is not set.
146
+ """
147
+ repo = Path(repo).resolve()
148
+ if not repo.is_dir():
149
+ raise InitError(f"not a directory: {repo}")
150
+ # `.git` may be a file (worktrees, submodules), so test existence, not is_dir.
151
+ if not (repo / ".git").exists():
152
+ raise InitError(f"{repo} is not a git repository root (no .git); run `git init` first")
153
+ project_file = repo / PROJECT_FILE
154
+ if overrides is not None and project_file.exists() and not force_project:
155
+ raise InitError(f"{project_file} already exists; edit it, or pass --force-project")
156
+ res = InitResult()
157
+ company = repo / ".company"
158
+ root = resources.files("caokit") / "data" / "scaffold" / "company"
159
+ for rel, src in _walk(root):
160
+ dest = company / rel
161
+ if dest.exists():
162
+ res.kept.append(".company/" + rel) # never clobber the project's own edits
163
+ continue
164
+ dest.parent.mkdir(parents=True, exist_ok=True)
165
+ dest.write_bytes(src.read_bytes())
166
+ res.created.append(".company/" + rel)
167
+ for d in EMPTY_DIRS:
168
+ (company / d).mkdir(parents=True, exist_ok=True)
169
+ if overrides is not None:
170
+ project_file.write_text(dump_overrides(overrides), encoding="utf-8")
171
+ res.created.append(str(PROJECT_FILE))
172
+ res.suggestion = suggest_validation(repo, runner=runner)
173
+ (company / "ROSTER.md").write_text(render_roster_md(cfg, res.suggestion), encoding="utf-8")
174
+ gi = repo / ".gitignore"
175
+ existing = gi.read_text(encoding="utf-8") if gi.exists() else ""
176
+ if _ignores_whole_company(existing):
177
+ res.warnings.append(
178
+ ".gitignore already ignores all of .company/; git cannot re-include "
179
+ ".company/contracts/ under an ignored parent. Remove that line so "
180
+ "contracts are tracked."
181
+ )
182
+ gi.write_text(merge_gitignore(existing), encoding="utf-8")
183
+ return res
caokit/install.py ADDED
@@ -0,0 +1,242 @@
1
+ """Render profiles from caokit.toml and install them into CAO."""
2
+
3
+ import os
4
+ import shutil
5
+ import tempfile
6
+ import time
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ from .adapters import ROLES, ProcessResult, Runner, default_runner, load_adapter
12
+ from .config import Config
13
+ from .render import profile_name, render_profile
14
+ from .roster import check_roster
15
+
16
+ # Profile names used by the pre-v1 bash kit. They stay in CAO's global store
17
+ # after migrating, so `install` offers to move them away.
18
+ LEGACY_NAMES = [
19
+ "startup_captain",
20
+ "claude_engineer",
21
+ "claude_reviewer",
22
+ "agy_researcher",
23
+ "agy_reviewer",
24
+ "codex_engineer",
25
+ "codex_reviewer",
26
+ ]
27
+
28
+
29
+ class InstallError(Exception):
30
+ """Raised when profiles cannot even be attempted (for example an invalid roster)."""
31
+
32
+
33
+ @dataclass
34
+ class InstallResult:
35
+ """Per-profile outcome of an install run."""
36
+
37
+ installed: list[str] = field(default_factory=list) # installed and verified
38
+ failed: list[str] = field(default_factory=list) # validate, install or verify failed
39
+ backup_dir: Path | None = None # set when stale copies were moved aside
40
+
41
+
42
+ def cao_home() -> Path:
43
+ """Return CAO's home directory.
44
+
45
+ Honors ``$CAO_HOME_DIR``, else ``~/.aws/cli-agent-orchestrator``. Because
46
+ ``~`` follows ``$HOME``, tests can redirect everything with a temp HOME.
47
+ """
48
+ env = os.environ.get("CAO_HOME_DIR")
49
+ return Path(env) if env else Path.home() / ".aws" / "cli-agent-orchestrator"
50
+
51
+
52
+ def render_all(cfg: Config) -> dict[str, str]:
53
+ """Render every roster profile.
54
+
55
+ Args:
56
+ cfg: The company configuration.
57
+
58
+ Returns:
59
+ Mapping of concrete profile name to full profile text. Names are
60
+ ``<role>-<tool>``, or ``<project>_<role>-<tool>`` for a project-scoped config.
61
+ """
62
+ out = {}
63
+ for role in ROLES:
64
+ b = cfg.roles[role]
65
+ name = profile_name(role, b.tool, cfg.project)
66
+ out[name] = render_profile(role, load_adapter(b.tool), b.model, cfg.project)
67
+ return out
68
+
69
+
70
+ def find_legacy(home: Path) -> list[str]:
71
+ """Return the legacy profile names that still have a copy under ``home``."""
72
+ return [
73
+ n
74
+ for n in LEGACY_NAMES
75
+ if (home / "agent-store" / (n + ".md")).exists()
76
+ or (home / "agent-context" / (n + ".md")).exists()
77
+ ]
78
+
79
+
80
+ def _backup(home: Path, names: list[str], backup: Path | None) -> Path | None:
81
+ """Move installed copies of ``names`` into a backup folder under ``home``.
82
+
83
+ Files are moved, never deleted, so a wrong guess is recoverable by hand.
84
+
85
+ Args:
86
+ home: CAO home directory.
87
+ names: Profile names whose ``agent-context`` and ``agent-store`` copies to move.
88
+ backup: Existing backup folder to reuse so one run produces one folder,
89
+ or None to create a timestamped one on first move.
90
+
91
+ Returns:
92
+ The backup folder (None if nothing was moved and none was supplied).
93
+ """
94
+ for n in names:
95
+ for sub in ("agent-context", "agent-store"):
96
+ src = home / sub / (n + ".md")
97
+ if src.exists():
98
+ if backup is None: # created lazily so a no-op leaves no empty folder
99
+ backup = home / ("caokit-backup-" + time.strftime("%Y%m%d%H%M%S"))
100
+ (backup / sub).mkdir(parents=True, exist_ok=True)
101
+ shutil.move(str(src), str(backup / sub / (n + ".md")))
102
+ return backup
103
+
104
+
105
+ def remove_legacy(home: Path, names: list[str]) -> Path | None:
106
+ """Move legacy profile copies to a backup folder (nothing is deleted).
107
+
108
+ Args:
109
+ home: CAO home directory.
110
+ names: Legacy profile names, as returned by :func:`find_legacy`.
111
+
112
+ Returns:
113
+ The backup folder, or None if there was nothing to move.
114
+ """
115
+ return _backup(home, names, None)
116
+
117
+
118
+ def _failed_output(r: ProcessResult) -> bool:
119
+ """Return True if a ``cao`` call failed.
120
+
121
+ CAO 2.5.x prints ``Error: ...`` but exits 0 when it refuses to overwrite a
122
+ profile copy from an older CAO, so the exit code alone is not trustworthy.
123
+ """
124
+ return r.returncode != 0 or "Error:" in ((r.stdout or "") + (r.stderr or ""))
125
+
126
+
127
+ def install_profiles(
128
+ cfg: Config,
129
+ home: Path | None = None,
130
+ runner: Runner = default_runner,
131
+ replace_legacy: bool = False,
132
+ out: Callable[[str], None] = print,
133
+ ) -> InstallResult:
134
+ """Render, validate, install and verify every profile in the roster.
135
+
136
+ Each profile is verified by comparing CAO's stored copy byte for byte with
137
+ what was rendered, because CAO stores profiles verbatim and a mismatch means
138
+ a stale or altered copy.
139
+
140
+ Args:
141
+ cfg: The company configuration.
142
+ home: CAO home directory (default: :func:`cao_home`).
143
+ runner: Command runner used to call ``cao`` (injectable for tests).
144
+ replace_legacy: On a refused install, move the stale copies to a
145
+ backup folder and retry once.
146
+ out: Sink for progress and failure messages.
147
+
148
+ Returns:
149
+ Which profiles were installed and which failed.
150
+
151
+ Raises:
152
+ InstallError: The roster violates a guardrail; nothing is attempted.
153
+ """
154
+ home = home or cao_home()
155
+ roster = check_roster(cfg)
156
+ if roster.errors:
157
+ raise InstallError("roster is invalid:\n " + "\n ".join(roster.errors))
158
+ profiles = render_all(cfg)
159
+ res = InstallResult()
160
+ with tempfile.TemporaryDirectory(prefix="caokit-") as stage:
161
+ for name, text in profiles.items():
162
+ # `cao install <file>` names the profile after the file stem, so the
163
+ # staged filename must be exactly the concrete profile name.
164
+ path = Path(stage) / (name + ".md")
165
+ path.write_text(text, encoding="utf-8")
166
+ v = runner(["cao", "profile", "validate", str(path)])
167
+ if v.returncode != 0: # validate exits 1 on schema errors; warnings exit 0
168
+ out(f"validate FAILED for {name}:\n{(v.stdout or '') + (v.stderr or '')}")
169
+ res.failed.append(name)
170
+ continue
171
+ r = runner(["cao", "install", str(path)])
172
+ if _failed_output(r) and replace_legacy:
173
+ res.backup_dir = _backup(home, [name], res.backup_dir)
174
+ out(f" moved stale copies of {name} to {res.backup_dir}; retrying")
175
+ r = runner(["cao", "install", str(path)])
176
+ if _failed_output(r):
177
+ detail = ((r.stdout or "") + (r.stderr or "")).strip()
178
+ out(f"install FAILED for {name}:\n{detail}")
179
+ res.failed.append(name)
180
+ continue
181
+ stored = home / "agent-store" / (name + ".md")
182
+ if not stored.is_file() or stored.read_bytes() != text.encode("utf-8"):
183
+ out(
184
+ f"install verification FAILED for {name}: "
185
+ "stored copy differs from rendered profile"
186
+ )
187
+ res.failed.append(name)
188
+ continue
189
+ out(f" installed {name} (verified byte for byte)")
190
+ res.installed.append(name)
191
+ return res
192
+
193
+
194
+ @dataclass
195
+ class UninstallResult:
196
+ """Per-profile outcome of an uninstall run."""
197
+
198
+ removed: list[str] = field(default_factory=list)
199
+ missing: list[str] = field(default_factory=list) # no copy anywhere in CAO's home
200
+ failed: list[str] = field(default_factory=list)
201
+ backup: Path | None = None # where leftover ``agent-context`` copies were moved
202
+
203
+
204
+ def uninstall_profiles(
205
+ cfg: Config,
206
+ home: Path | None = None,
207
+ runner: Runner = default_runner,
208
+ apply: bool = False,
209
+ ) -> UninstallResult:
210
+ """Remove the roster's profiles from CAO.
211
+
212
+ ``cao profile remove`` deletes only the ``agent-store`` copy. CAO also keeps an
213
+ ``agent-context`` copy that keeps the profile listed as installed, so that copy is
214
+ moved to a backup folder (never deleted). Only the names this config renders are
215
+ touched, so one project's ``<project>_*`` profiles go and no other project's do.
216
+ Reinstalling restores them.
217
+
218
+ Args:
219
+ cfg: The configuration whose profiles to remove.
220
+ home: CAO home directory (default :func:`cao_home`).
221
+ runner: Command runner (injectable for tests).
222
+ apply: Actually remove. When False nothing is run and every profile with a copy
223
+ is reported as ``removed``, so the caller can show what would happen.
224
+
225
+ Returns:
226
+ Which profiles were (or would be) removed, were not installed, or failed.
227
+ """
228
+ home = home or cao_home()
229
+ res = UninstallResult()
230
+ for name in render_all(cfg):
231
+ in_store = (home / "agent-store" / (name + ".md")).exists()
232
+ in_context = (home / "agent-context" / (name + ".md")).exists()
233
+ if not (in_store or in_context):
234
+ res.missing.append(name)
235
+ elif not apply:
236
+ res.removed.append(name)
237
+ elif in_store and _failed_output(runner(["cao", "profile", "remove", name, "--yes"])):
238
+ res.failed.append(name)
239
+ else:
240
+ res.backup = _backup(home, [name], res.backup)
241
+ res.removed.append(name)
242
+ return res
caokit/launch.py ADDED
@@ -0,0 +1,102 @@
1
+ """Build the ``cao`` argv for launching, inspecting, messaging and stopping a company session.
2
+
3
+ Every command is a pure function from arguments to an argv list, so tests can check exactly
4
+ what would run. The CLI layer is the only caller that executes them.
5
+ """
6
+
7
+ import hashlib
8
+ import re
9
+ from pathlib import Path
10
+
11
+ SESSION_PREFIX = "cao-" # CAO prepends this to the --session-name it is given
12
+
13
+
14
+ def session_name(project: Path, slug: str | None) -> str:
15
+ """Pick the CAO session name for a project.
16
+
17
+ Args:
18
+ project: Project directory.
19
+ slug: The project's namespace slug if it has one.
20
+
21
+ Returns:
22
+ ``slug`` if given, else the directory name reduced to lowercase letters, digits and
23
+ ``-`` plus a short hash of the full path, so two checkouts that share a directory
24
+ name (``a/app`` and ``b/app``) still get different sessions.
25
+ """
26
+ if slug:
27
+ return slug
28
+ root = Path(project).resolve()
29
+ name = re.sub(r"[^a-z0-9]+", "-", root.name.lower()).strip("-")[:40].strip("-") or "company"
30
+ return f"{name}-{hashlib.sha1(str(root).encode()).hexdigest()[:6]}"
31
+
32
+
33
+ def session_id(name: str) -> str:
34
+ """Return the ID CAO gives a session started with ``--session-name name``.
35
+
36
+ CAO adds its prefix only when the name does not already start with it, so a project
37
+ called ``cao-foo`` is the session ``cao-foo``, not ``cao-cao-foo``.
38
+ """
39
+ return name if name.startswith(SESSION_PREFIX) else SESSION_PREFIX + name
40
+
41
+
42
+ def build_launch_argv(
43
+ project: Path,
44
+ profile: str,
45
+ session: str | None = None,
46
+ prompt: str | None = None,
47
+ headless: bool = False,
48
+ auto_approve: bool = False,
49
+ wait: bool = True,
50
+ ) -> list[str]:
51
+ """Build the argv that launches a CAO session for ``profile`` in ``project``.
52
+
53
+ The working directory is always explicit and absolute: without it CAO uses
54
+ the caller's cwd, which once led the General to start in the kit repo
55
+ instead of the project.
56
+
57
+ Args:
58
+ project: Project directory the session must run in (relative paths are resolved).
59
+ profile: Concrete CAO profile name, e.g. ``general-claude``.
60
+ session: Optional CAO session name.
61
+ prompt: Optional first message for the General.
62
+ headless: Start detached instead of attaching to the terminal.
63
+ auto_approve: Skip CAO's launch confirmation. Never grants extra tool access.
64
+ wait: With ``headless`` and a prompt, wait up to 300s for the General's reply. False
65
+ passes ``--async``: return once the prompt is delivered.
66
+
67
+ Returns:
68
+ The argv list, starting with ``cao launch``. Never includes ``--yolo``.
69
+
70
+ Raises:
71
+ ValueError: If ``project`` is not an existing directory.
72
+ """
73
+ root = Path(project).resolve()
74
+ if not root.is_dir():
75
+ raise ValueError(f"project directory does not exist: {root}")
76
+ argv = ["cao", "launch", "--agents", profile, "--working-directory", str(root)]
77
+ if session:
78
+ argv += ["--session-name", session]
79
+ if headless:
80
+ argv.append("--headless")
81
+ if auto_approve:
82
+ argv.append("--auto-approve")
83
+ if not wait:
84
+ argv.append("--async")
85
+ if prompt:
86
+ argv += ["--", prompt] # "--" so a prompt starting with "-" is not read as an option
87
+ return argv
88
+
89
+
90
+ def build_status_argv(session: str) -> list[list[str]]:
91
+ """Build the commands that show every session and one session's workers."""
92
+ return [["cao", "session", "list"], ["cao", "session", "status", session, "--workers"]]
93
+
94
+
95
+ def build_send_argv(session: str, message: str, timeout: int = 600) -> list[str]:
96
+ """Build the command that sends ``message`` to a session's General."""
97
+ return ["cao", "session", "send", "--timeout", str(timeout), "--", session, message]
98
+
99
+
100
+ def build_stop_argv(session: str) -> list[str]:
101
+ """Build the command that shuts one session down. It asks no confirmation."""
102
+ return ["cao", "shutdown", "--session", session]