mootup 0.2.0__tar.gz → 0.2.2__tar.gz
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.
- {mootup-0.2.0 → mootup-0.2.2}/PKG-INFO +1 -1
- {mootup-0.2.0 → mootup-0.2.2}/README.md +1 -1
- {mootup-0.2.0 → mootup-0.2.2}/pyproject.toml +1 -1
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/__init__.py +1 -1
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/adapters/channel_runner.py +7 -1
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/adapters/mcp_adapter.py +12 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/adapters/mcp_runner.py +7 -1
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/adapters/notification_core.py +9 -1
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/cli.py +5 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/config.py +14 -1
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/devcontainer.py +49 -30
- mootup-0.2.2/src/moot/launch.py +384 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/lifecycle.py +58 -4
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/team_profile.py +6 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/devcontainer/devcontainer.json +3 -0
- mootup-0.2.2/src/moot/templates/devcontainer/post-create.sh +68 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/devcontainer/run-moot-channel.sh +18 -7
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/devcontainer/run-moot-mcp.sh +21 -5
- {mootup-0.2.0 → mootup-0.2.2}/tests/test_devcontainer.py +137 -33
- mootup-0.2.2/tests/test_launch.py +388 -0
- mootup-0.2.2/tests/test_lifecycle.py +241 -0
- {mootup-0.2.0 → mootup-0.2.2}/tests/test_templates.py +143 -9
- {mootup-0.2.0 → mootup-0.2.2}/uv.lock +1 -1
- mootup-0.2.0/docs/specs/devcontainer-orchestration.md +0 -1617
- mootup-0.2.0/docs/specs/moot-cli-brand-login.md +0 -806
- mootup-0.2.0/docs/specs/moot-init-full-provisioning.md +0 -2150
- mootup-0.2.0/src/moot/launch.py +0 -196
- mootup-0.2.0/src/moot/templates/devcontainer/post-create.sh +0 -23
- mootup-0.2.0/tests/test_launch.py +0 -193
- mootup-0.2.0/tests/test_lifecycle.py +0 -122
- {mootup-0.2.0 → mootup-0.2.2}/.github/workflows/publish.yml +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/.gitignore +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/LICENSE +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/__main__.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/adapters/__init__.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/adapters/channel_adapter.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/adapters/notify_runner.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/adapters/tmux_delivery.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/auth.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/id_encoding.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/models.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/provision.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/response_format.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/scaffold.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/CLAUDE.md +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/devcontainer/run-moot-notify.sh +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/skills/doc-curation/SKILL.md +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/skills/handoff/SKILL.md +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/skills/leader-workflow/SKILL.md +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/skills/librarian-workflow/SKILL.md +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/skills/product-workflow/SKILL.md +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/skills/spec-checklist/SKILL.md +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/skills/verify/SKILL.md +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/teams/loop-3/CLAUDE.md +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/teams/loop-3/README.md +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/teams/loop-3/team.toml +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/teams/loop-4/CLAUDE.md +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/teams/loop-4/README.md +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/teams/loop-4/team.toml +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/teams/loop-4-observer/CLAUDE.md +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/teams/loop-4-observer/README.md +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/teams/loop-4-observer/team.toml +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/teams/loop-4-parallel/CLAUDE.md +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/teams/loop-4-parallel/README.md +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/teams/loop-4-parallel/team.toml +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/teams/loop-4-split-leader/CLAUDE.md +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/teams/loop-4-split-leader/README.md +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/src/moot/templates/teams/loop-4-split-leader/team.toml +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/tests/__init__.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/tests/test_adapters/__init__.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/tests/test_auth.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/tests/test_cli.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/tests/test_config.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/tests/test_example.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/tests/test_models.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/tests/test_package.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/tests/test_provision.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/tests/test_response_format.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/tests/test_scaffold.py +0 -0
- {mootup-0.2.0 → mootup-0.2.2}/tests/test_security.py +0 -0
|
@@ -158,7 +158,7 @@ Created by `moot init`. Contains rotated API keys keyed by lower-cased role name
|
|
|
158
158
|
```json
|
|
159
159
|
{
|
|
160
160
|
"space_id": "spc_...",
|
|
161
|
-
"space_name": "
|
|
161
|
+
"space_name": "My Space",
|
|
162
162
|
"api_url": "https://mootup.io",
|
|
163
163
|
"actors": {
|
|
164
164
|
"product": {"actor_id": "agt_...", "api_key": "convo_...", "display_name": "Product"},
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""moot — CLI + MCP adapters for Moot agent teams."""
|
|
2
|
-
__version__ = "0.2.
|
|
2
|
+
__version__ = "0.2.2"
|
|
@@ -33,8 +33,14 @@ from moot.adapters.channel_adapter import ChannelAdapter
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
async def main() -> None:
|
|
36
|
+
# Default to DEBUG during alpha so ops can reconstruct what the adapter
|
|
37
|
+
# actually did when users report "nothing happened" bugs. MOOT_LOG_LEVEL
|
|
38
|
+
# lets us dial it back to INFO/WARNING once alpha stabilizes.
|
|
39
|
+
level_name = os.environ.get("MOOT_LOG_LEVEL", "DEBUG").upper()
|
|
40
|
+
level = getattr(logging, level_name, logging.DEBUG)
|
|
36
41
|
logging.basicConfig(
|
|
37
|
-
level=
|
|
42
|
+
level=level,
|
|
43
|
+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
38
44
|
)
|
|
39
45
|
|
|
40
46
|
api_url = os.environ.get("CONVO_API_URL", "http://localhost:8000")
|
|
@@ -275,6 +275,18 @@ class MCPSpaceAdapter:
|
|
|
275
275
|
)
|
|
276
276
|
if resp.status_code == 403:
|
|
277
277
|
raise ValueError(f"Forbidden (403): {resp.text[:200]}")
|
|
278
|
+
# 409 is handled by specific callers (post_response maps it to a
|
|
279
|
+
# structured error for the agent); everything else in the 4xx range
|
|
280
|
+
# is a bug the caller needs to see, not silently swallow as an empty
|
|
281
|
+
# event_id or similar shape-valid-but-wrong response.
|
|
282
|
+
if 400 <= resp.status_code < 500 and resp.status_code != 409:
|
|
283
|
+
self.logger.error(
|
|
284
|
+
"%s %s → %d: %s", method, url, resp.status_code, resp.text[:200]
|
|
285
|
+
)
|
|
286
|
+
raise ValueError(
|
|
287
|
+
f"Backend rejected request {resp.status_code} on {method} "
|
|
288
|
+
f"{url}: {resp.text[:200]}"
|
|
289
|
+
)
|
|
278
290
|
return resp
|
|
279
291
|
|
|
280
292
|
def _register_tools(self) -> None:
|
|
@@ -37,8 +37,14 @@ from moot.adapters.mcp_adapter import MCPSpaceAdapter
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
async def main() -> None:
|
|
40
|
+
# Default to DEBUG during alpha so ops can reconstruct what the adapter
|
|
41
|
+
# actually did when users report "nothing happened" bugs. MOOT_LOG_LEVEL
|
|
42
|
+
# lets us dial it back to INFO/WARNING once alpha stabilizes.
|
|
43
|
+
level_name = os.environ.get("MOOT_LOG_LEVEL", "DEBUG").upper()
|
|
44
|
+
level = getattr(logging, level_name, logging.DEBUG)
|
|
40
45
|
logging.basicConfig(
|
|
41
|
-
level=
|
|
46
|
+
level=level,
|
|
47
|
+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
42
48
|
)
|
|
43
49
|
parser = argparse.ArgumentParser(description="MCP space adapter")
|
|
44
50
|
parser.add_argument("--transport", choices=["stdio", "http"], default="stdio")
|
|
@@ -142,11 +142,19 @@ class NotificationCore(ABC):
|
|
|
142
142
|
async def _stream_loop(self, space_id: str) -> None:
|
|
143
143
|
ws_url = self._ws_url(space_id)
|
|
144
144
|
logger.info("WS connecting to %s", ws_url[:80])
|
|
145
|
+
# websockets requires a truthy ssl= value for wss:// URIs; passing
|
|
146
|
+
# ssl=None is treated as "no TLS" and raises ValueError. Default to
|
|
147
|
+
# True (uses the system CA bundle) and only build a custom context
|
|
148
|
+
# when SSL_CERT_FILE is set (dev stacks with a private CA).
|
|
145
149
|
ssl_cert = os.environ.get("SSL_CERT_FILE")
|
|
146
|
-
ssl_context: Any
|
|
150
|
+
ssl_context: Any
|
|
147
151
|
if ssl_cert:
|
|
148
152
|
import ssl
|
|
149
153
|
ssl_context = ssl.create_default_context(cafile=ssl_cert)
|
|
154
|
+
elif ws_url.startswith("wss://"):
|
|
155
|
+
ssl_context = True
|
|
156
|
+
else:
|
|
157
|
+
ssl_context = None # ws:// — plain TCP, no TLS
|
|
150
158
|
async with websockets.connect(
|
|
151
159
|
ws_url, ssl=ssl_context, open_timeout=10,
|
|
152
160
|
proxy=None, # disable proxy auto-detection (v16 default)
|
|
@@ -104,6 +104,8 @@ def main() -> None:
|
|
|
104
104
|
compact_p.add_argument("role", nargs="?", help="Compact specific role")
|
|
105
105
|
attach_p = sub.add_parser("attach", help="Attach to agent tmux session")
|
|
106
106
|
attach_p.add_argument("role")
|
|
107
|
+
detach_p = sub.add_parser("detach", help="Detach from agent tmux session")
|
|
108
|
+
detach_p.add_argument("role")
|
|
107
109
|
|
|
108
110
|
args = parser.parse_args()
|
|
109
111
|
if not args.command:
|
|
@@ -142,3 +144,6 @@ def main() -> None:
|
|
|
142
144
|
elif args.command == "attach":
|
|
143
145
|
from moot.lifecycle import cmd_attach
|
|
144
146
|
cmd_attach(args)
|
|
147
|
+
elif args.command == "detach":
|
|
148
|
+
from moot.lifecycle import cmd_detach
|
|
149
|
+
cmd_detach(args)
|
|
@@ -19,7 +19,12 @@ class AgentConfig:
|
|
|
19
19
|
self.profile: str = data.get("profile", "devcontainer")
|
|
20
20
|
self.startup_prompt: str = data.get(
|
|
21
21
|
"startup_prompt",
|
|
22
|
-
|
|
22
|
+
(
|
|
23
|
+
f"You are the {role.title()} agent. Call orientation() to "
|
|
24
|
+
f"get your identity, focus space, and recent context in one "
|
|
25
|
+
f"call. Then subscribe to the channel and post a "
|
|
26
|
+
f"status_update confirming you are online."
|
|
27
|
+
),
|
|
23
28
|
)
|
|
24
29
|
|
|
25
30
|
|
|
@@ -36,6 +41,14 @@ class MootConfig:
|
|
|
36
41
|
self.agents: dict[str, AgentConfig] = {}
|
|
37
42
|
for role, agent_data in data.get("agents", {}).items():
|
|
38
43
|
self.agents[role] = AgentConfig(role, agent_data)
|
|
44
|
+
# Role the operator talks to directly. `moot up` launches this one
|
|
45
|
+
# first on a cold start (no claude credentials yet) so first-time login
|
|
46
|
+
# happens through its tmux session — the rest of the team starts
|
|
47
|
+
# once credentials exist.
|
|
48
|
+
self.human_interface: str = harness.get(
|
|
49
|
+
"human_interface",
|
|
50
|
+
"product" if "product" in self.agents else (next(iter(self.agents), "")),
|
|
51
|
+
)
|
|
39
52
|
|
|
40
53
|
@property
|
|
41
54
|
def roles(self) -> list[str]:
|
|
@@ -13,7 +13,6 @@ appear on the bash command line (ps, scrollback, tmux env dump).
|
|
|
13
13
|
"""
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
|
-
import json
|
|
17
16
|
import shutil
|
|
18
17
|
import subprocess
|
|
19
18
|
from pathlib import Path
|
|
@@ -36,42 +35,38 @@ def ensure_cli() -> None:
|
|
|
36
35
|
def up(workspace: Path) -> str:
|
|
37
36
|
"""Boot (or rediscover) the devcontainer for `workspace`; return its id.
|
|
38
37
|
|
|
39
|
-
Runs `devcontainer up --workspace-folder <workspace
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
`
|
|
43
|
-
|
|
38
|
+
Runs `devcontainer up --workspace-folder <workspace>` in default text
|
|
39
|
+
log format and streams stdout/stderr to the user's terminal. After the
|
|
40
|
+
subprocess exits, looks up the running container id via
|
|
41
|
+
`container_id_or_none()` (which queries the `devcontainer.local_folder`
|
|
42
|
+
label). On non-zero exit, raises `DevcontainerError` with the exit
|
|
43
|
+
code — the actual error output is already on the user's terminal, so
|
|
44
|
+
we don't re-embed it in the exception message.
|
|
45
|
+
|
|
46
|
+
Prints a pre-build hint only when no container is currently running
|
|
47
|
+
for this workspace. The CLI's idempotent re-up path is ~0.35s, so
|
|
48
|
+
we don't short-circuit; we just suppress the "can take 1-3 minutes"
|
|
49
|
+
line that would be misleading on a re-up.
|
|
44
50
|
"""
|
|
45
51
|
ensure_cli()
|
|
52
|
+
already_running = container_id_or_none(workspace) is not None
|
|
53
|
+
if not already_running:
|
|
54
|
+
print(
|
|
55
|
+
f"Building devcontainer in {workspace} "
|
|
56
|
+
"(first launch can take 1-3 minutes)..."
|
|
57
|
+
)
|
|
46
58
|
proc = subprocess.run(
|
|
47
|
-
[
|
|
48
|
-
"devcontainer", "up",
|
|
49
|
-
"--workspace-folder", str(workspace),
|
|
50
|
-
"--log-format", "json",
|
|
51
|
-
],
|
|
52
|
-
capture_output=True,
|
|
53
|
-
text=True,
|
|
59
|
+
["devcontainer", "up", "--workspace-folder", str(workspace)],
|
|
54
60
|
)
|
|
55
|
-
|
|
56
|
-
if not lines:
|
|
57
|
-
raise DevcontainerError(
|
|
58
|
-
f"devcontainer up produced no output (rc={proc.returncode}): "
|
|
59
|
-
f"{proc.stderr.strip()[:500]}"
|
|
60
|
-
)
|
|
61
|
-
try:
|
|
62
|
-
result = json.loads(lines[-1])
|
|
63
|
-
except json.JSONDecodeError as e:
|
|
61
|
+
if proc.returncode != 0:
|
|
64
62
|
raise DevcontainerError(
|
|
65
|
-
f"
|
|
66
|
-
f"line: {lines[-1][:500]}"
|
|
63
|
+
f"devcontainer up failed (exit code {proc.returncode})"
|
|
67
64
|
)
|
|
68
|
-
|
|
69
|
-
msg = result.get("message") or result.get("description") or "unknown error"
|
|
70
|
-
raise DevcontainerError(f"devcontainer up failed: {msg}")
|
|
71
|
-
container_id = result.get("containerId")
|
|
65
|
+
container_id = container_id_or_none(workspace)
|
|
72
66
|
if not container_id:
|
|
73
67
|
raise DevcontainerError(
|
|
74
|
-
"devcontainer up
|
|
68
|
+
f"devcontainer up exited 0 but no running container was found "
|
|
69
|
+
f"for {workspace}"
|
|
75
70
|
)
|
|
76
71
|
return container_id
|
|
77
72
|
|
|
@@ -154,6 +149,30 @@ def exec_interactive(container_id: str, args: list[str]) -> None:
|
|
|
154
149
|
tmux attach-session (or any interactive command) works naturally.
|
|
155
150
|
Does not raise on nonzero rc — interactive commands routinely exit
|
|
156
151
|
nonzero on Ctrl-C or Ctrl-D and that is not an error to surface.
|
|
152
|
+
|
|
153
|
+
Sets TERM=xterm-256color + a UTF-8 LANG inside the container so the
|
|
154
|
+
tmux client has a terminfo entry it can find (the container's
|
|
155
|
+
terminfo DB is limited — `xterm-24bits`, `xterm-kitty`, `alacritty`
|
|
156
|
+
and other host-specific entries typically aren't present and tmux
|
|
157
|
+
refuses to start with "missing or unsuitable terminal"). Propagates
|
|
158
|
+
COLORTERM through from the host so modern TUIs still pick up
|
|
159
|
+
truecolor. These together are what keep claude's TUI logo, borders,
|
|
160
|
+
and colors rendering correctly across host terminals.
|
|
157
161
|
"""
|
|
158
|
-
|
|
162
|
+
import os
|
|
163
|
+
|
|
164
|
+
term_env = [
|
|
165
|
+
"-e", "TERM=xterm-256color",
|
|
166
|
+
"-e", "LANG=C.UTF-8",
|
|
167
|
+
]
|
|
168
|
+
colorterm = os.environ.get("COLORTERM")
|
|
169
|
+
if colorterm:
|
|
170
|
+
term_env += ["-e", f"COLORTERM={colorterm}"]
|
|
171
|
+
|
|
172
|
+
cmd = (
|
|
173
|
+
["docker", "exec", "-it", "--user", "node"]
|
|
174
|
+
+ term_env
|
|
175
|
+
+ [container_id]
|
|
176
|
+
+ args
|
|
177
|
+
)
|
|
159
178
|
subprocess.run(cmd, check=False)
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Launch Claude agents in tmux sessions inside the bundled devcontainer."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import shlex
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from moot.config import MootConfig, find_config
|
|
10
|
+
from moot.devcontainer import (
|
|
11
|
+
container_id_or_none,
|
|
12
|
+
exec_capture,
|
|
13
|
+
exec_detached,
|
|
14
|
+
up,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
CREDENTIALS_PATH = "/home/node/.claude/.credentials.json"
|
|
19
|
+
SETTINGS_PATH = "/home/node/.claude/settings.json"
|
|
20
|
+
AUTH_POLL_INTERVAL_S = 5.0
|
|
21
|
+
# Short grace period after first-run state has fully landed on disk.
|
|
22
|
+
# Claude writes credentials + settings incrementally during the theme +
|
|
23
|
+
# login flow; a second of slack ensures the last write has flushed
|
|
24
|
+
# before we spawn siblings that read those files.
|
|
25
|
+
AUTH_SETTLE_S = 2.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _session_name(role: str) -> str:
|
|
29
|
+
return f"moot-{role}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _credentials_present(container_id: str) -> bool:
|
|
33
|
+
"""True if claude's host-side credentials file exists in the container."""
|
|
34
|
+
rc, _stdout, _stderr = exec_capture(
|
|
35
|
+
container_id,
|
|
36
|
+
["test", "-s", CREDENTIALS_PATH],
|
|
37
|
+
)
|
|
38
|
+
return rc == 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _first_run_ready(container_id: str) -> bool:
|
|
42
|
+
"""True if both credentials AND settings.json exist.
|
|
43
|
+
|
|
44
|
+
settings.json is written by claude after the user dismisses the
|
|
45
|
+
theme picker (a per-user choice that follows /login in the first-run
|
|
46
|
+
flow). Waiting for both signals that the user has finished the
|
|
47
|
+
user-scope portion of onboarding, so sibling agents launched after
|
|
48
|
+
this point won't re-hit the theme prompt. Per-worktree prompts
|
|
49
|
+
(dev-use approval, workspace trust) are unavoidable — each agent
|
|
50
|
+
must go through those regardless.
|
|
51
|
+
"""
|
|
52
|
+
rc, _stdout, _stderr = exec_capture(
|
|
53
|
+
container_id,
|
|
54
|
+
[
|
|
55
|
+
"bash", "-c",
|
|
56
|
+
f"test -s {CREDENTIALS_PATH} && test -s {SETTINGS_PATH}",
|
|
57
|
+
],
|
|
58
|
+
)
|
|
59
|
+
return rc == 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _wait_for_credentials(container_id: str, human_interface: str) -> None:
|
|
63
|
+
"""Block until claude first-run setup is complete in the container.
|
|
64
|
+
|
|
65
|
+
Called on cold-start after launching the human-interface role so the
|
|
66
|
+
user can `moot attach <role>`, complete /login + theme + any other
|
|
67
|
+
first-run prompts, then detach. Polls until both credentials and
|
|
68
|
+
settings.json are present, plus a short settle to let the final
|
|
69
|
+
writes flush. Ctrl-C cancels the cascade; the human-interface
|
|
70
|
+
session keeps running so the user can rerun `moot up` later.
|
|
71
|
+
"""
|
|
72
|
+
print(
|
|
73
|
+
f"\nFirst-time setup: claude authentication required.\n"
|
|
74
|
+
f" 1. In another terminal: moot attach {human_interface}\n"
|
|
75
|
+
f" 2. Complete /login, pick a theme, approve the dev-use "
|
|
76
|
+
f"notice, and reach the claude prompt.\n"
|
|
77
|
+
f" 3. Detach (Ctrl-Space d or /detach).\n"
|
|
78
|
+
)
|
|
79
|
+
print("Waiting for first-run setup ", end="", flush=True)
|
|
80
|
+
try:
|
|
81
|
+
while not _first_run_ready(container_id):
|
|
82
|
+
time.sleep(AUTH_POLL_INTERVAL_S)
|
|
83
|
+
print(".", end="", flush=True)
|
|
84
|
+
except KeyboardInterrupt:
|
|
85
|
+
print(
|
|
86
|
+
f"\nAborted. {human_interface} is still running; rerun `moot up` "
|
|
87
|
+
f"after finishing first-run setup to launch the rest of the team."
|
|
88
|
+
)
|
|
89
|
+
sys.exit(130)
|
|
90
|
+
print(" ✓")
|
|
91
|
+
time.sleep(AUTH_SETTLE_S)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
DEV_USE_PROMPT_DELAY_S = 5.0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _auto_dismiss_dev_use_prompt(
|
|
98
|
+
container_id: str, roles: list[str]
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Send Enter to each freshly-launched agent to dismiss the per-
|
|
101
|
+
workspace 'I am using this for development use only' disclaimer.
|
|
102
|
+
|
|
103
|
+
The disclaimer is per-claude-workspace; each worktree hits it on
|
|
104
|
+
first start. On cold-start cascade, every sibling agent starts in
|
|
105
|
+
a fresh worktree and would otherwise require a manual `moot attach`
|
|
106
|
+
+ Enter per role to reach the prompt. We dismiss it automatically
|
|
107
|
+
so the team is fully online after `moot up` returns.
|
|
108
|
+
|
|
109
|
+
The human-interface role is excluded because the user has already
|
|
110
|
+
dismissed its prompt manually during the login/theme setup pass.
|
|
111
|
+
"""
|
|
112
|
+
print(f"Dismissing dev-use disclaimer on {len(roles)} agent(s)...")
|
|
113
|
+
time.sleep(DEV_USE_PROMPT_DELAY_S)
|
|
114
|
+
for role in roles:
|
|
115
|
+
session = _session_name(role)
|
|
116
|
+
exec_capture(
|
|
117
|
+
container_id,
|
|
118
|
+
["tmux", "send-keys", "-t", session, "Enter"],
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _session_exists(container_id: str, role: str) -> bool:
|
|
123
|
+
"""True if a tmux session for `role` exists inside the container."""
|
|
124
|
+
rc, _stdout, _stderr = exec_capture(
|
|
125
|
+
container_id,
|
|
126
|
+
["tmux", "has-session", "-t", _session_name(role)],
|
|
127
|
+
)
|
|
128
|
+
return rc == 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _ensure_worktree(container_id: str, project: str, role: str) -> str:
|
|
132
|
+
"""Ensure `.worktrees/<role>` exists in the bind-mounted workspace.
|
|
133
|
+
|
|
134
|
+
Returns the in-container worktree path. The devcontainer CLI mounts
|
|
135
|
+
the host workspace at /workspaces/{cwd.name}; git worktrees created
|
|
136
|
+
inside the container are visible on the host via the bind mount,
|
|
137
|
+
but the agent runs git ops inside the container where paths resolve
|
|
138
|
+
consistently.
|
|
139
|
+
"""
|
|
140
|
+
wt_path = f"/workspaces/{project}/.worktrees/{role}"
|
|
141
|
+
rc, _stdout, _stderr = exec_capture(
|
|
142
|
+
container_id,
|
|
143
|
+
["test", "-d", wt_path],
|
|
144
|
+
)
|
|
145
|
+
if rc == 0:
|
|
146
|
+
return wt_path
|
|
147
|
+
branch = f"{role}/work"
|
|
148
|
+
rc, _stdout, stderr = exec_capture(
|
|
149
|
+
container_id,
|
|
150
|
+
[
|
|
151
|
+
"bash", "-c",
|
|
152
|
+
f"cd /workspaces/{shlex.quote(project)} && "
|
|
153
|
+
f"git worktree prune && "
|
|
154
|
+
f"(git worktree add {shlex.quote(wt_path)} -b {shlex.quote(branch)} HEAD "
|
|
155
|
+
f" || git worktree add {shlex.quote(wt_path)} {shlex.quote(branch)})",
|
|
156
|
+
],
|
|
157
|
+
)
|
|
158
|
+
if rc != 0:
|
|
159
|
+
raise RuntimeError(
|
|
160
|
+
f"failed to create worktree {wt_path!r}: {stderr.strip()}"
|
|
161
|
+
)
|
|
162
|
+
return wt_path
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _launch_role(
|
|
166
|
+
container_id: str,
|
|
167
|
+
config: MootConfig,
|
|
168
|
+
role: str,
|
|
169
|
+
prompt_override: str | None,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Launch a single role into a tmux session inside the container.
|
|
172
|
+
|
|
173
|
+
Assumes `container_id` is a running devcontainer for the current
|
|
174
|
+
workspace. Returns silently if the session is already running.
|
|
175
|
+
Called by both cmd_exec (single role) and cmd_up (loop).
|
|
176
|
+
"""
|
|
177
|
+
session = _session_name(role)
|
|
178
|
+
if _session_exists(container_id, role):
|
|
179
|
+
print(f"{role} already running in {session}")
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
project = Path.cwd().name
|
|
183
|
+
wt_path = _ensure_worktree(container_id, project, role)
|
|
184
|
+
|
|
185
|
+
agent_config = config.agents[role]
|
|
186
|
+
prompt = prompt_override or agent_config.startup_prompt
|
|
187
|
+
|
|
188
|
+
# The claude command is built INLINE (per D2). The two literal strings
|
|
189
|
+
# '--dangerously-load-development-channels' and 'server:convo-channel'
|
|
190
|
+
# ALSO appear in cmd_exec's docstring as an anchor for the existing
|
|
191
|
+
# inspect.getsource-based test (test_launch_includes_channel_flag).
|
|
192
|
+
match config.harness_type:
|
|
193
|
+
case "claude-code":
|
|
194
|
+
# Use `--` to seed the first user turn while keeping claude in
|
|
195
|
+
# interactive TUI mode. `-p` runs in print mode and exits as
|
|
196
|
+
# soon as the response is emitted, which kills the tmux session.
|
|
197
|
+
claude_cmd = (
|
|
198
|
+
"claude --dangerously-skip-permissions "
|
|
199
|
+
"--dangerously-load-development-channels server:convo-channel "
|
|
200
|
+
f"-- {shlex.quote(prompt)}"
|
|
201
|
+
)
|
|
202
|
+
case _:
|
|
203
|
+
print(f"Error: harness '{config.harness_type}' not yet supported")
|
|
204
|
+
raise SystemExit(1)
|
|
205
|
+
|
|
206
|
+
# Per-role env that MUST override the tmux server's env. Tmux sessions
|
|
207
|
+
# created inside an existing server inherit the SERVER's env (set when
|
|
208
|
+
# the server started, from the FIRST role launched), not the env of
|
|
209
|
+
# the shell that invoked `new-session`. So every per-role value has
|
|
210
|
+
# to go through `tmux new-session -e` — otherwise spec/impl/qa all
|
|
211
|
+
# run with product's CONVO_ROLE and connect to convo as Product.
|
|
212
|
+
#
|
|
213
|
+
# CONVO_API_KEY is deliberately NOT included: the convo MCP wrapper
|
|
214
|
+
# scripts look it up from .moot/actors.json at runtime using
|
|
215
|
+
# CONVO_ROLE. Keeping it out of env also keeps the secret off every
|
|
216
|
+
# tmux / ps command line.
|
|
217
|
+
pane_env: dict[str, str] = {
|
|
218
|
+
"CONVO_ROLE": role,
|
|
219
|
+
"CONVO_API_URL": config.api_url,
|
|
220
|
+
}
|
|
221
|
+
tmux_e_flags = " ".join(
|
|
222
|
+
f"-e {shlex.quote(f'{k}={v}')}" for k, v in pane_env.items()
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
tmux_cmd = (
|
|
226
|
+
f"tmux -u new-session -d -s {shlex.quote(session)} "
|
|
227
|
+
f"{tmux_e_flags} "
|
|
228
|
+
f"-c {shlex.quote(wt_path)} "
|
|
229
|
+
f"-- {claude_cmd}"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# docker exec env: shared settings that become the tmux SERVER env on
|
|
233
|
+
# first launch and are inherited by all subsequent sessions (since
|
|
234
|
+
# they don't vary per role). TERM/LANG/COLORTERM keep claude's TUI
|
|
235
|
+
# rendering correct; TMUX_TMPDIR pins the socket path for /detach.
|
|
236
|
+
env: dict[str, str] = {
|
|
237
|
+
"TERM": "xterm-256color",
|
|
238
|
+
"COLORTERM": "truecolor",
|
|
239
|
+
"LANG": "C.UTF-8",
|
|
240
|
+
"LC_ALL": "C.UTF-8",
|
|
241
|
+
"TMUX_TMPDIR": "/tmp",
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
rc, _stdout, stderr = exec_capture(
|
|
245
|
+
container_id,
|
|
246
|
+
["bash", "-lc", tmux_cmd],
|
|
247
|
+
env=env,
|
|
248
|
+
)
|
|
249
|
+
if rc != 0:
|
|
250
|
+
print(f"Error launching {role}: {stderr.strip()}")
|
|
251
|
+
raise SystemExit(1)
|
|
252
|
+
print(f"Launched {role} in {session}")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def cmd_exec(args: object) -> None:
|
|
256
|
+
"""Launch a single agent.
|
|
257
|
+
|
|
258
|
+
The two literal strings below are the `test_launch_includes_channel_flag`
|
|
259
|
+
anchor — keep them in cmd_exec's textual source:
|
|
260
|
+
--dangerously-load-development-channels server:convo-channel
|
|
261
|
+
"""
|
|
262
|
+
role = getattr(args, "role")
|
|
263
|
+
prompt_override = getattr(args, "prompt", None)
|
|
264
|
+
|
|
265
|
+
config = find_config()
|
|
266
|
+
if not config:
|
|
267
|
+
print("Error: no moot.toml found. Run 'moot init' first.")
|
|
268
|
+
raise SystemExit(1)
|
|
269
|
+
|
|
270
|
+
if role not in config.agents:
|
|
271
|
+
print(f"Error: unknown role '{role}'. Available: {', '.join(config.roles)}")
|
|
272
|
+
raise SystemExit(1)
|
|
273
|
+
|
|
274
|
+
container_id = up(Path.cwd())
|
|
275
|
+
if not _credentials_present(container_id):
|
|
276
|
+
print(
|
|
277
|
+
f"Error: claude credentials not yet present in container. "
|
|
278
|
+
f"Run `moot up` first (it launches {config.human_interface} so you "
|
|
279
|
+
f"can complete /login)."
|
|
280
|
+
)
|
|
281
|
+
raise SystemExit(1)
|
|
282
|
+
_launch_role(container_id, config, role, prompt_override)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def cmd_up(args: object) -> None:
|
|
286
|
+
"""Start all (or selected) agents. Boots the container once.
|
|
287
|
+
|
|
288
|
+
Cold-start cascade: if claude's credentials file is not yet present
|
|
289
|
+
inside the container, launch ONLY the human-interface role first,
|
|
290
|
+
block until the user completes /login (poll for the credentials file),
|
|
291
|
+
then launch the remaining roles. This matches the coclaude one-login
|
|
292
|
+
UX without dropping the user into a standalone claude instance — they
|
|
293
|
+
log in through their actual product session.
|
|
294
|
+
|
|
295
|
+
Warm-start: credentials already exist, launch all requested roles in
|
|
296
|
+
parallel (subsecond).
|
|
297
|
+
|
|
298
|
+
On success prints a closing summary: `Started <N> agents in container
|
|
299
|
+
<short-id>. Connect with 'moot attach <role>' or check 'moot status'.`
|
|
300
|
+
N counts roles that were launched OR already running. If `_launch_role`
|
|
301
|
+
raises (e.g. tmux command failed), the summary is not printed.
|
|
302
|
+
"""
|
|
303
|
+
config = find_config()
|
|
304
|
+
if not config:
|
|
305
|
+
print("Error: no moot.toml found. Run 'moot init' first.")
|
|
306
|
+
raise SystemExit(1)
|
|
307
|
+
|
|
308
|
+
only = getattr(args, "only", None)
|
|
309
|
+
roles: list[str] = only.split(",") if only else config.roles
|
|
310
|
+
|
|
311
|
+
container_id = up(Path.cwd())
|
|
312
|
+
|
|
313
|
+
cold_start = not _credentials_present(container_id)
|
|
314
|
+
if cold_start:
|
|
315
|
+
hi = config.human_interface
|
|
316
|
+
if hi not in roles:
|
|
317
|
+
print(
|
|
318
|
+
f"Error: cold start requires the human-interface role "
|
|
319
|
+
f"'{hi}' to be in the launch set (got: {', '.join(roles)}). "
|
|
320
|
+
f"Omit --only or include {hi} so first-time /login can run."
|
|
321
|
+
)
|
|
322
|
+
raise SystemExit(1)
|
|
323
|
+
if hi not in config.agents:
|
|
324
|
+
print(
|
|
325
|
+
f"Error: human_interface = '{hi}' in moot.toml, but no such "
|
|
326
|
+
f"agent is configured. Fix [harness].human_interface."
|
|
327
|
+
)
|
|
328
|
+
raise SystemExit(1)
|
|
329
|
+
_launch_role(container_id, config, hi, prompt_override=None)
|
|
330
|
+
_wait_for_credentials(container_id, hi)
|
|
331
|
+
roles = [r for r in roles if r != hi]
|
|
332
|
+
|
|
333
|
+
alive = 1 if cold_start else 0
|
|
334
|
+
cascaded_roles: list[str] = []
|
|
335
|
+
for role in roles:
|
|
336
|
+
if role not in config.agents:
|
|
337
|
+
print(f"Warning: unknown role '{role}', skipping")
|
|
338
|
+
continue
|
|
339
|
+
_launch_role(container_id, config, role, prompt_override=None)
|
|
340
|
+
cascaded_roles.append(role)
|
|
341
|
+
alive += 1
|
|
342
|
+
|
|
343
|
+
if cold_start and cascaded_roles:
|
|
344
|
+
_auto_dismiss_dev_use_prompt(container_id, cascaded_roles)
|
|
345
|
+
|
|
346
|
+
print(
|
|
347
|
+
f"Started {alive} agents in container {container_id[:12]}. "
|
|
348
|
+
f"Connect with 'moot attach <role>' or check 'moot status'."
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def cmd_down(args: object) -> None:
|
|
353
|
+
"""Stop agent tmux sessions inside the devcontainer.
|
|
354
|
+
|
|
355
|
+
Does NOT stop the devcontainer itself — a user who wants to fully
|
|
356
|
+
shut it down runs `docker stop <container>` manually. That's a
|
|
357
|
+
future `moot container down` concern, out of this run's scope.
|
|
358
|
+
"""
|
|
359
|
+
config = find_config()
|
|
360
|
+
if not config:
|
|
361
|
+
print("Error: no moot.toml found. Run 'moot init' first.")
|
|
362
|
+
raise SystemExit(1)
|
|
363
|
+
|
|
364
|
+
container_id = container_id_or_none(Path.cwd())
|
|
365
|
+
if container_id is None:
|
|
366
|
+
print("No devcontainer running for this workspace.")
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
role = getattr(args, "role", None)
|
|
370
|
+
roles = [role] if role else config.roles
|
|
371
|
+
|
|
372
|
+
for r in roles:
|
|
373
|
+
session = _session_name(r)
|
|
374
|
+
if _session_exists(container_id, r):
|
|
375
|
+
rc, _stdout, _stderr = exec_capture(
|
|
376
|
+
container_id,
|
|
377
|
+
["tmux", "kill-session", "-t", session],
|
|
378
|
+
)
|
|
379
|
+
if rc == 0:
|
|
380
|
+
print(f"Stopped {session}")
|
|
381
|
+
else:
|
|
382
|
+
print(f"Warning: tmux kill-session -t {session} returned rc={rc}")
|
|
383
|
+
else:
|
|
384
|
+
print(f"{session} not running")
|