mootup 0.2.1__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.
Files changed (83) hide show
  1. {mootup-0.2.1 → mootup-0.2.2}/PKG-INFO +1 -1
  2. {mootup-0.2.1 → mootup-0.2.2}/README.md +1 -1
  3. {mootup-0.2.1 → mootup-0.2.2}/pyproject.toml +1 -1
  4. {mootup-0.2.1 → mootup-0.2.2}/src/moot/adapters/channel_runner.py +7 -1
  5. {mootup-0.2.1 → mootup-0.2.2}/src/moot/adapters/mcp_adapter.py +12 -0
  6. {mootup-0.2.1 → mootup-0.2.2}/src/moot/adapters/mcp_runner.py +7 -1
  7. {mootup-0.2.1 → mootup-0.2.2}/src/moot/adapters/notification_core.py +9 -1
  8. {mootup-0.2.1 → mootup-0.2.2}/src/moot/cli.py +5 -0
  9. {mootup-0.2.1 → mootup-0.2.2}/src/moot/config.py +14 -1
  10. {mootup-0.2.1 → mootup-0.2.2}/src/moot/devcontainer.py +25 -1
  11. mootup-0.2.2/src/moot/launch.py +384 -0
  12. {mootup-0.2.1 → mootup-0.2.2}/src/moot/lifecycle.py +58 -4
  13. {mootup-0.2.1 → mootup-0.2.2}/src/moot/team_profile.py +6 -0
  14. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/devcontainer/devcontainer.json +3 -0
  15. mootup-0.2.2/src/moot/templates/devcontainer/post-create.sh +68 -0
  16. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/devcontainer/run-moot-channel.sh +18 -7
  17. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/devcontainer/run-moot-mcp.sh +21 -5
  18. {mootup-0.2.1 → mootup-0.2.2}/tests/test_devcontainer.py +36 -5
  19. mootup-0.2.2/tests/test_launch.py +388 -0
  20. mootup-0.2.2/tests/test_lifecycle.py +241 -0
  21. {mootup-0.2.1 → mootup-0.2.2}/tests/test_templates.py +77 -22
  22. {mootup-0.2.1 → mootup-0.2.2}/uv.lock +1 -1
  23. mootup-0.2.1/docs/publish.md +0 -84
  24. mootup-0.2.1/docs/specs/devcontainer-orchestration.md +0 -1617
  25. mootup-0.2.1/docs/specs/moot-cli-brand-login.md +0 -806
  26. mootup-0.2.1/docs/specs/moot-init-full-provisioning.md +0 -2150
  27. mootup-0.2.1/docs/specs/moot-up-progress-output.md +0 -592
  28. mootup-0.2.1/docs/specs/post-create-fixes.md +0 -424
  29. mootup-0.2.1/src/moot/launch.py +0 -218
  30. mootup-0.2.1/src/moot/templates/devcontainer/post-create.sh +0 -28
  31. mootup-0.2.1/tests/test_launch.py +0 -205
  32. mootup-0.2.1/tests/test_lifecycle.py +0 -122
  33. {mootup-0.2.1 → mootup-0.2.2}/.github/workflows/publish.yml +0 -0
  34. {mootup-0.2.1 → mootup-0.2.2}/.gitignore +0 -0
  35. {mootup-0.2.1 → mootup-0.2.2}/LICENSE +0 -0
  36. {mootup-0.2.1 → mootup-0.2.2}/src/moot/__init__.py +0 -0
  37. {mootup-0.2.1 → mootup-0.2.2}/src/moot/__main__.py +0 -0
  38. {mootup-0.2.1 → mootup-0.2.2}/src/moot/adapters/__init__.py +0 -0
  39. {mootup-0.2.1 → mootup-0.2.2}/src/moot/adapters/channel_adapter.py +0 -0
  40. {mootup-0.2.1 → mootup-0.2.2}/src/moot/adapters/notify_runner.py +0 -0
  41. {mootup-0.2.1 → mootup-0.2.2}/src/moot/adapters/tmux_delivery.py +0 -0
  42. {mootup-0.2.1 → mootup-0.2.2}/src/moot/auth.py +0 -0
  43. {mootup-0.2.1 → mootup-0.2.2}/src/moot/id_encoding.py +0 -0
  44. {mootup-0.2.1 → mootup-0.2.2}/src/moot/models.py +0 -0
  45. {mootup-0.2.1 → mootup-0.2.2}/src/moot/provision.py +0 -0
  46. {mootup-0.2.1 → mootup-0.2.2}/src/moot/response_format.py +0 -0
  47. {mootup-0.2.1 → mootup-0.2.2}/src/moot/scaffold.py +0 -0
  48. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/CLAUDE.md +0 -0
  49. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/devcontainer/run-moot-notify.sh +0 -0
  50. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/skills/doc-curation/SKILL.md +0 -0
  51. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/skills/handoff/SKILL.md +0 -0
  52. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/skills/leader-workflow/SKILL.md +0 -0
  53. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/skills/librarian-workflow/SKILL.md +0 -0
  54. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/skills/product-workflow/SKILL.md +0 -0
  55. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/skills/spec-checklist/SKILL.md +0 -0
  56. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/skills/verify/SKILL.md +0 -0
  57. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/teams/loop-3/CLAUDE.md +0 -0
  58. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/teams/loop-3/README.md +0 -0
  59. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/teams/loop-3/team.toml +0 -0
  60. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/teams/loop-4/CLAUDE.md +0 -0
  61. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/teams/loop-4/README.md +0 -0
  62. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/teams/loop-4/team.toml +0 -0
  63. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/teams/loop-4-observer/CLAUDE.md +0 -0
  64. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/teams/loop-4-observer/README.md +0 -0
  65. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/teams/loop-4-observer/team.toml +0 -0
  66. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/teams/loop-4-parallel/CLAUDE.md +0 -0
  67. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/teams/loop-4-parallel/README.md +0 -0
  68. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/teams/loop-4-parallel/team.toml +0 -0
  69. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/teams/loop-4-split-leader/CLAUDE.md +0 -0
  70. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/teams/loop-4-split-leader/README.md +0 -0
  71. {mootup-0.2.1 → mootup-0.2.2}/src/moot/templates/teams/loop-4-split-leader/team.toml +0 -0
  72. {mootup-0.2.1 → mootup-0.2.2}/tests/__init__.py +0 -0
  73. {mootup-0.2.1 → mootup-0.2.2}/tests/test_adapters/__init__.py +0 -0
  74. {mootup-0.2.1 → mootup-0.2.2}/tests/test_auth.py +0 -0
  75. {mootup-0.2.1 → mootup-0.2.2}/tests/test_cli.py +0 -0
  76. {mootup-0.2.1 → mootup-0.2.2}/tests/test_config.py +0 -0
  77. {mootup-0.2.1 → mootup-0.2.2}/tests/test_example.py +0 -0
  78. {mootup-0.2.1 → mootup-0.2.2}/tests/test_models.py +0 -0
  79. {mootup-0.2.1 → mootup-0.2.2}/tests/test_package.py +0 -0
  80. {mootup-0.2.1 → mootup-0.2.2}/tests/test_provision.py +0 -0
  81. {mootup-0.2.1 → mootup-0.2.2}/tests/test_response_format.py +0 -0
  82. {mootup-0.2.1 → mootup-0.2.2}/tests/test_scaffold.py +0 -0
  83. {mootup-0.2.1 → mootup-0.2.2}/tests/test_security.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mootup
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: CLI + MCP adapters for Moot agent teams
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -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": "Pat's Space",
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "mootup"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "CLI + MCP adapters for Moot agent teams"
5
5
  requires-python = ">=3.11"
6
6
  license = "Apache-2.0"
@@ -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=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s"
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=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s"
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 = None
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
- f"Run your startup protocol from CLAUDE.md. You are the {role.title()} agent.",
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]:
@@ -149,6 +149,30 @@ def exec_interactive(container_id: str, args: list[str]) -> None:
149
149
  tmux attach-session (or any interactive command) works naturally.
150
150
  Does not raise on nonzero rc — interactive commands routinely exit
151
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.
152
161
  """
153
- cmd = ["docker", "exec", "-it", "--user", "node", container_id] + args
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
+ )
154
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")
@@ -63,9 +63,14 @@ def cmd_compact(args: object) -> None:
63
63
  def cmd_attach(args: object) -> None:
64
64
  """Attach to an agent's tmux session via `docker exec -it`.
65
65
 
66
+ If the session doesn't exist yet (e.g., the user /exit'd claude and
67
+ tore down the tmux session), relaunch the role inline instead of
68
+ erroring — saves a `moot up` round-trip. Requires the container to
69
+ be up and claude credentials to already be warm; if not, point at
70
+ `moot up` which handles cold-start cascade.
71
+
66
72
  Blocks until the user detaches. No post-exit output — tmux handles
67
- its own display. If the session or container is missing, exits 1
68
- with an error.
73
+ its own display.
69
74
  """
70
75
  role = getattr(args, "role")
71
76
  container_id = container_id_or_none(Path.cwd())
@@ -75,10 +80,59 @@ def cmd_attach(args: object) -> None:
75
80
 
76
81
  session = _session_name(role)
77
82
  if not _session_exists(container_id, role):
78
- print(f"Error: {session} not running")
79
- raise SystemExit(1)
83
+ # Auto-relaunch the role. Lazy import to avoid circularity
84
+ # through lifecycle ← launch ← config dependency chain.
85
+ from moot.config import find_config
86
+ from moot.launch import _credentials_present, _launch_role
87
+
88
+ config = find_config()
89
+ if config is None:
90
+ print("Error: no moot.toml found. Run 'moot init' first.")
91
+ raise SystemExit(1)
92
+ if role not in config.agents:
93
+ print(
94
+ f"Error: unknown role '{role}'. "
95
+ f"Available: {', '.join(config.roles)}"
96
+ )
97
+ raise SystemExit(1)
98
+ if not _credentials_present(container_id):
99
+ print(
100
+ "Error: claude credentials not yet present. Run `moot up` "
101
+ "to complete first-time setup."
102
+ )
103
+ raise SystemExit(1)
104
+ print(f"{session} not running — launching...")
105
+ _launch_role(container_id, config, role, prompt_override=None)
80
106
 
81
107
  exec_interactive(
82
108
  container_id,
83
109
  ["tmux", "attach-session", "-t", session],
84
110
  )
111
+
112
+
113
+ def cmd_detach(args: object) -> None:
114
+ """Detach any attached client from an agent's tmux session.
115
+
116
+ Leaves claude running inside the session; only disconnects the
117
+ terminal. Complements `moot attach` when the user can't press the
118
+ tmux prefix from inside claude (claude intercepts Ctrl-B). The
119
+ bundled .tmux.conf also rebinds the prefix to Ctrl-Space, so
120
+ `<prefix> d` works from inside a session — this command is the
121
+ external escape hatch.
122
+ """
123
+ role = getattr(args, "role")
124
+ container_id = container_id_or_none(Path.cwd())
125
+ if container_id is None:
126
+ print("No devcontainer running for this workspace.")
127
+ return
128
+
129
+ session = _session_name(role)
130
+ if not _session_exists(container_id, role):
131
+ print(f"{session} not running")
132
+ return
133
+
134
+ exec_capture(
135
+ container_id,
136
+ ["tmux", "detach-client", "-s", session],
137
+ )
138
+ print(f"Detached all clients from {session}")
@@ -191,6 +191,12 @@ def generate_moot_toml(
191
191
  lines.append("[harness]")
192
192
  lines.append(f'type = "{harness}"')
193
193
  lines.append('permissions = "dangerously-skip"')
194
+ # Role the operator talks to directly. moot up launches this one first
195
+ # on a cold start so claude /login happens in its tmux session. Default to
196
+ # product if present, otherwise the first role.
197
+ role_names = [r.name for r in profile.roles]
198
+ hi = "product" if "product" in role_names else (role_names[0] if role_names else "")
199
+ lines.append(f'human_interface = "{hi}"')
194
200
  lines.append("")
195
201
 
196
202
  return "\n".join(lines)
@@ -10,6 +10,9 @@
10
10
  }
11
11
  },
12
12
  "postCreateCommand": "bash .devcontainer/post-create.sh",
13
+ "runArgs": [
14
+ "--name", "moot-${localWorkspaceFolderBasename}"
15
+ ],
13
16
  "customizations": {
14
17
  "vscode": {
15
18
  "extensions": [