jarvix-cli 0.1.0__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.
- jarvix_cli-0.1.0/.gitignore +30 -0
- jarvix_cli-0.1.0/LICENSE +21 -0
- jarvix_cli-0.1.0/PKG-INFO +24 -0
- jarvix_cli-0.1.0/README.md +0 -0
- jarvix_cli-0.1.0/pyproject.toml +46 -0
- jarvix_cli-0.1.0/src/jarvix/cli/__init__.py +86 -0
- jarvix_cli-0.1.0/src/jarvix/cli/__main__.py +4 -0
- jarvix_cli-0.1.0/src/jarvix/cli/launch.py +67 -0
- jarvix_cli-0.1.0/src/jarvix/cli/mesh.py +205 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
*.egg
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.eggs/
|
|
9
|
+
|
|
10
|
+
# Virtual environments
|
|
11
|
+
.venv/
|
|
12
|
+
venv/
|
|
13
|
+
env/
|
|
14
|
+
|
|
15
|
+
# Tooling caches
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.coverage
|
|
20
|
+
htmlcov/
|
|
21
|
+
|
|
22
|
+
# Environment
|
|
23
|
+
.env
|
|
24
|
+
.env.*
|
|
25
|
+
!.env.example
|
|
26
|
+
|
|
27
|
+
# Editors / OS
|
|
28
|
+
.idea/
|
|
29
|
+
.vscode/
|
|
30
|
+
.DS_Store
|
jarvix_cli-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 loctvl842
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: jarvix-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Jarvix command line interface
|
|
5
|
+
Project-URL: Homepage, https://github.com/loctvl842/jarvix
|
|
6
|
+
Project-URL: Repository, https://github.com/loctvl842/jarvix
|
|
7
|
+
Project-URL: Issues, https://github.com/loctvl842/jarvix/issues
|
|
8
|
+
Author-email: loctvl842 <loclepnvx@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,ai,cli,multiplexer,tmux
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Requires-Dist: click>=8.1
|
|
22
|
+
Requires-Dist: jarvix-core>=0.1.0
|
|
23
|
+
Requires-Dist: jarvix-hub>=0.1.0
|
|
24
|
+
Requires-Dist: jarvix-mux>=0.1.0
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[tool.hatch.build.targets.wheel]
|
|
6
|
+
packages = ["src/jarvix"]
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "jarvix-cli"
|
|
10
|
+
version = "0.1.0"
|
|
11
|
+
description = "Jarvix command line interface"
|
|
12
|
+
authors = [{ name = "loctvl842", email = "loclepnvx@gmail.com" }]
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
requires-python = ">=3.12"
|
|
15
|
+
license = "MIT"
|
|
16
|
+
license-files = ["LICENSE"]
|
|
17
|
+
keywords = ["ai", "agents", "tmux", "multiplexer", "cli"]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Environment :: Console",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Operating System :: MacOS",
|
|
23
|
+
"Operating System :: POSIX :: Linux",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"jarvix-core>=0.1.0",
|
|
30
|
+
"jarvix-hub>=0.1.0",
|
|
31
|
+
"jarvix-mux>=0.1.0",
|
|
32
|
+
"click>=8.1",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/loctvl842/jarvix"
|
|
37
|
+
Repository = "https://github.com/loctvl842/jarvix"
|
|
38
|
+
Issues = "https://github.com/loctvl842/jarvix/issues"
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
jarvix = "jarvix.cli:main"
|
|
42
|
+
|
|
43
|
+
[tool.uv.sources]
|
|
44
|
+
jarvix-core = { workspace = true }
|
|
45
|
+
jarvix-hub = { workspace = true }
|
|
46
|
+
jarvix-mux = { workspace = true }
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from jarvix.hub.client import send
|
|
9
|
+
from jarvix.hub.paths import socket_path
|
|
10
|
+
|
|
11
|
+
from .mesh import mesh, mesh_option
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group()
|
|
15
|
+
def main() -> None:
|
|
16
|
+
"""Jarvix CLI."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
main.add_command(mesh)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@main.command(hidden=True, context_settings={"ignore_unknown_options": True})
|
|
23
|
+
@click.argument("event")
|
|
24
|
+
def hook(event: str) -> None:
|
|
25
|
+
"""Forward a harness hook event (stdin JSON) to the hub. Never fails the harness."""
|
|
26
|
+
try:
|
|
27
|
+
node = os.environ.get("JARVIX_NODE")
|
|
28
|
+
sock = os.environ.get("JARVIX_SOCKET")
|
|
29
|
+
if not node or not sock:
|
|
30
|
+
return
|
|
31
|
+
try:
|
|
32
|
+
payload = json.loads(sys.stdin.read() or "{}")
|
|
33
|
+
except ValueError:
|
|
34
|
+
payload = {}
|
|
35
|
+
reply = send(
|
|
36
|
+
sock, {"op": "event", "node": node, "event": {"name": event, "ts": time.time(), "payload": payload}}
|
|
37
|
+
)
|
|
38
|
+
if reply and reply.get("output"):
|
|
39
|
+
click.echo(reply["output"])
|
|
40
|
+
except Exception: # noqa: BLE001 - a broken hook must never break the harness
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _age(since: float) -> str:
|
|
45
|
+
secs = max(0, int(time.time() - since))
|
|
46
|
+
if secs < 60:
|
|
47
|
+
return f"{secs}s"
|
|
48
|
+
if secs < 3600:
|
|
49
|
+
return f"{secs // 60}m"
|
|
50
|
+
return f"{secs // 3600}h{secs % 3600 // 60:02d}m"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@main.command()
|
|
54
|
+
@mesh_option
|
|
55
|
+
@click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
|
|
56
|
+
def status(mesh: str, as_json: bool) -> None:
|
|
57
|
+
"""Show the state of every node."""
|
|
58
|
+
reply = send(socket_path(mesh), {"op": "nodes"})
|
|
59
|
+
if reply is None or not reply.get("ok"):
|
|
60
|
+
raise click.ClickException(f"hub for mesh {mesh!r} is not running")
|
|
61
|
+
nodes = reply["nodes"]
|
|
62
|
+
if as_json:
|
|
63
|
+
click.echo(json.dumps(nodes, indent=2))
|
|
64
|
+
return
|
|
65
|
+
width = max([len(n["name"]) for n in nodes] + [4])
|
|
66
|
+
click.echo(f"{'NODE':<{width}} {'STATE':<8} {'SINCE':<6} {'ATTN':<4} SUMMARY")
|
|
67
|
+
for n in nodes:
|
|
68
|
+
attn = "!" if n["attention"] else "-"
|
|
69
|
+
click.echo(f"{n['name']:<{width}} {n['state']:<8} {_age(n['state_since']):<6} {attn:<4} {n['summary']}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@main.command()
|
|
73
|
+
@mesh_option
|
|
74
|
+
@click.argument("node")
|
|
75
|
+
@click.option("-n", default=10, show_default=True, help="Number of recent events.")
|
|
76
|
+
def logs(mesh: str, node: str, n: int) -> None:
|
|
77
|
+
"""Show recent events for a node."""
|
|
78
|
+
reply = send(socket_path(mesh), {"op": "status", "node": node, "n": n})
|
|
79
|
+
if reply is None:
|
|
80
|
+
raise click.ClickException(f"hub for mesh {mesh!r} is not running")
|
|
81
|
+
if not reply.get("ok"):
|
|
82
|
+
raise click.ClickException(reply["error"])
|
|
83
|
+
for e in reply["events"]:
|
|
84
|
+
stamp = datetime.fromtimestamp(e["ts"]).strftime("%H:%M:%S")
|
|
85
|
+
detail = e["payload"].get("tool_name") or e["payload"].get("prompt") or e["payload"].get("message") or ""
|
|
86
|
+
click.echo(f"{stamp} {e['kind']:<18} {detail[:80]}")
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shlex
|
|
3
|
+
import sys
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from jarvix.hub.hooks import hook_settings
|
|
8
|
+
from jarvix.hub.paths import runtime_dir
|
|
9
|
+
from jarvix.hub.server import NodeSpec
|
|
10
|
+
from jarvix.mux import LayoutStrategy, Multiplexer, PaneRef, SessionRef
|
|
11
|
+
|
|
12
|
+
NODES_WINDOW = "nodes"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def default_jarvix_bin() -> str:
|
|
16
|
+
"""Command the hooks run. Uses this interpreter so it works without `jarvix` on PATH."""
|
|
17
|
+
return shlex.join([sys.executable, "-m", "jarvix.cli"])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def session_name(mesh: str) -> str:
|
|
21
|
+
return f"jarvix-{mesh}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def tmux_socket(mesh: str) -> str:
|
|
25
|
+
"""Private tmux server per mesh, so the user's own tmux is untouched."""
|
|
26
|
+
return f"jarvix-{mesh}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_settings(mesh: str, jarvix_bin: str) -> Path:
|
|
30
|
+
path = runtime_dir(mesh) / "claude-settings.json"
|
|
31
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
path.write_text(json.dumps(hook_settings(jarvix_bin), indent=2))
|
|
33
|
+
return path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def node_argv(spec: NodeSpec, settings: Path, *, model: str, claude_bin: str = "claude") -> list[str]:
|
|
37
|
+
if spec.harness != "claude":
|
|
38
|
+
raise ValueError(f"unsupported harness {spec.harness!r} for node {spec.name!r}")
|
|
39
|
+
# --model is required: a model pinned in the user's global settings can break turns (M0 gotcha).
|
|
40
|
+
return [claude_bin, "--model", model, "--settings", str(settings)]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def launch_nodes(
|
|
44
|
+
mux: Multiplexer,
|
|
45
|
+
mesh: str,
|
|
46
|
+
specs: Sequence[NodeSpec],
|
|
47
|
+
sock_path: Path,
|
|
48
|
+
settings: Path,
|
|
49
|
+
*,
|
|
50
|
+
model: str,
|
|
51
|
+
claude_bin: str = "claude",
|
|
52
|
+
) -> dict[str, PaneRef]:
|
|
53
|
+
"""Spawn one pane per node in a fresh session and arrange them in a grid. The hub must already listen."""
|
|
54
|
+
session: SessionRef = mux.spawn_session(session_name(mesh))
|
|
55
|
+
panes: dict[str, PaneRef] = {}
|
|
56
|
+
for spec in specs:
|
|
57
|
+
env = {"JARVIX_NODE": spec.name, "JARVIX_SOCKET": str(sock_path), "JARVIX_MESH": mesh}
|
|
58
|
+
panes[spec.name] = mux.spawn_pane(
|
|
59
|
+
session,
|
|
60
|
+
node_argv(spec, settings, model=model, claude_bin=claude_bin),
|
|
61
|
+
env=env,
|
|
62
|
+
cwd=Path(spec.cwd),
|
|
63
|
+
tag=spec.name,
|
|
64
|
+
window=NODES_WINDOW,
|
|
65
|
+
)
|
|
66
|
+
mux.layout(session, LayoutStrategy.GRID, window=NODES_WINDOW)
|
|
67
|
+
return panes
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from jarvix.hub import (
|
|
13
|
+
Hub,
|
|
14
|
+
HubAlreadyRunning,
|
|
15
|
+
NodeSpec,
|
|
16
|
+
Store,
|
|
17
|
+
db_path,
|
|
18
|
+
encode_pane,
|
|
19
|
+
log_path,
|
|
20
|
+
pid_path,
|
|
21
|
+
send,
|
|
22
|
+
socket_path,
|
|
23
|
+
)
|
|
24
|
+
from jarvix.mux import SessionRef, TmuxMultiplexer
|
|
25
|
+
from jarvix.mux.tmux import MuxError
|
|
26
|
+
|
|
27
|
+
from .launch import (
|
|
28
|
+
default_jarvix_bin,
|
|
29
|
+
launch_nodes,
|
|
30
|
+
session_name,
|
|
31
|
+
tmux_socket,
|
|
32
|
+
write_settings,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
mesh_option = click.option("--mesh", envvar="JARVIX_MESH", default="default", show_default=True, help="Mesh name.")
|
|
36
|
+
|
|
37
|
+
STARTUP_TIMEOUT = 20.0
|
|
38
|
+
STOP_TIMEOUT = 5.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@click.group()
|
|
42
|
+
def mesh() -> None:
|
|
43
|
+
"""Run a mesh of harness nodes in tmux panes."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _hub_pid(mesh: str) -> int | None:
|
|
47
|
+
"""Pid of the background hub, or None if there is no live one."""
|
|
48
|
+
try:
|
|
49
|
+
pid = int(pid_path(mesh).read_text())
|
|
50
|
+
os.kill(pid, 0)
|
|
51
|
+
except (OSError, ValueError):
|
|
52
|
+
return None
|
|
53
|
+
return pid
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _tail(path: Path, n: int = 15) -> str:
|
|
57
|
+
try:
|
|
58
|
+
return "\n".join(path.read_text().splitlines()[-n:])
|
|
59
|
+
except OSError:
|
|
60
|
+
return ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _print_hints(mesh: str, count: int) -> None:
|
|
64
|
+
click.echo(f"mesh {mesh!r} up with {count} node(s)")
|
|
65
|
+
click.echo(f" jarvix mesh attach --mesh {mesh} # see the nodes")
|
|
66
|
+
click.echo(f" jarvix status --mesh {mesh} # state of every node")
|
|
67
|
+
click.echo(f" jarvix mesh logs --mesh {mesh} -f # hub log")
|
|
68
|
+
click.echo(f" jarvix mesh down --mesh {mesh} # stop the hub and kill the panes")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _serve(mesh: str, specs: list[NodeSpec], model: str, claude_bin: str, clean_config: bool) -> None:
|
|
72
|
+
"""Run the hub in this process and spawn the nodes once it listens. Returns when stopped."""
|
|
73
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S")
|
|
74
|
+
sock = socket_path(mesh)
|
|
75
|
+
sock.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
store = Store(db_path(mesh))
|
|
77
|
+
mux = TmuxMultiplexer(socket=tmux_socket(mesh), config_file="/dev/null" if clean_config else None)
|
|
78
|
+
server = Hub(sock, store, specs, mux=mux)
|
|
79
|
+
settings = write_settings(mesh, default_jarvix_bin())
|
|
80
|
+
|
|
81
|
+
def spawn() -> None:
|
|
82
|
+
# Hooks are fire-and-forget, so the hub must be listening before the first SessionStart.
|
|
83
|
+
try:
|
|
84
|
+
panes = launch_nodes(mux, mesh, specs, sock, settings, model=model, claude_bin=claude_bin)
|
|
85
|
+
except MuxError as e:
|
|
86
|
+
raise click.ClickException(f"{e}\nIs the mesh already up? Try: jarvix mesh down --mesh {mesh}") from e
|
|
87
|
+
for name, pane in panes.items():
|
|
88
|
+
store.set_pane(name, encode_pane(pane))
|
|
89
|
+
logging.info("spawned %d node(s): %s", len(panes), ", ".join(panes))
|
|
90
|
+
|
|
91
|
+
async def run() -> None:
|
|
92
|
+
loop = asyncio.get_running_loop()
|
|
93
|
+
task = asyncio.current_task()
|
|
94
|
+
assert task is not None
|
|
95
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
96
|
+
loop.add_signal_handler(sig, task.cancel)
|
|
97
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
98
|
+
await server.serve_forever(on_ready=spawn)
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
pid_path(mesh).write_text(str(os.getpid()))
|
|
102
|
+
asyncio.run(run())
|
|
103
|
+
except HubAlreadyRunning as e:
|
|
104
|
+
raise click.ClickException(str(e)) from e
|
|
105
|
+
finally:
|
|
106
|
+
with contextlib.suppress(FileNotFoundError):
|
|
107
|
+
pid_path(mesh).unlink()
|
|
108
|
+
store.close()
|
|
109
|
+
logging.info("hub stopped")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _wait_until_up(mesh: str, names: list[str], child: subprocess.Popen) -> None:
|
|
113
|
+
"""Block until the hub answers and every node has a pane, or fail with the hub log."""
|
|
114
|
+
deadline = time.monotonic() + STARTUP_TIMEOUT
|
|
115
|
+
while time.monotonic() < deadline:
|
|
116
|
+
if child.poll() is not None:
|
|
117
|
+
raise click.ClickException(f"hub exited during startup:\n{_tail(log_path(mesh))}")
|
|
118
|
+
reply = send(socket_path(mesh), {"op": "nodes"}, timeout=0.5)
|
|
119
|
+
if reply and reply["ok"]:
|
|
120
|
+
panes = {n["name"]: n["pane_ref"] for n in reply["nodes"]}
|
|
121
|
+
if all(panes.get(name) for name in names):
|
|
122
|
+
return
|
|
123
|
+
time.sleep(0.1)
|
|
124
|
+
raise click.ClickException(f"hub did not come up in {STARTUP_TIMEOUT:.0f}s:\n{_tail(log_path(mesh))}")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@mesh.command("up")
|
|
128
|
+
@mesh_option
|
|
129
|
+
@click.option("--node", "nodes", multiple=True, required=True, help="Node name. Repeatable.")
|
|
130
|
+
@click.option("--cwd", default=".", show_default=True, help="Working directory of every node.")
|
|
131
|
+
@click.option("--model", default="sonnet", show_default=True, help="Model passed to claude.")
|
|
132
|
+
@click.option("--claude-bin", default="claude", show_default=True, help="The claude executable.")
|
|
133
|
+
@click.option("--clean-config", is_flag=True, help="Ignore ~/.tmux.conf for the mesh's tmux server.")
|
|
134
|
+
@click.option("--foreground", is_flag=True, help="Run the hub in this terminal instead of the background.")
|
|
135
|
+
def mesh_up(
|
|
136
|
+
mesh: str, nodes: tuple[str, ...], cwd: str, model: str, claude_bin: str, clean_config: bool, foreground: bool
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Spawn one Claude Code pane per node and run the hub (in the background by default)."""
|
|
139
|
+
# TODO: temporary entry point. The real one is `jarvix up <graph>`: compile() the graph into a launch plan
|
|
140
|
+
# (per-node harness, cwd, prompt, model, plus edges and Jarvis) and feed it to launch_nodes, instead of --node flags.
|
|
141
|
+
if send(socket_path(mesh), {"op": "ping"}, timeout=0.5):
|
|
142
|
+
raise click.ClickException(f"mesh {mesh!r} is already up. Stop it first: jarvix mesh down --mesh {mesh}")
|
|
143
|
+
specs = [NodeSpec(name=n, cwd=os.path.abspath(cwd)) for n in nodes]
|
|
144
|
+
if foreground:
|
|
145
|
+
if sys.stdout.isatty(): # the background child inherits a log file, where this banner would be noise
|
|
146
|
+
click.echo(f"hub for mesh {mesh!r} running in this terminal. Ctrl-C stops it; nodes keep running.")
|
|
147
|
+
_serve(mesh, specs, model, claude_bin, clean_config)
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
log = log_path(mesh)
|
|
151
|
+
log.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
argv = [sys.executable, "-m", "jarvix.cli", "mesh", "up", "--foreground", "--mesh", mesh, "--cwd", cwd]
|
|
153
|
+
argv += ["--model", model, "--claude-bin", claude_bin]
|
|
154
|
+
argv += ["--clean-config"] if clean_config else []
|
|
155
|
+
for n in nodes:
|
|
156
|
+
argv += ["--node", n]
|
|
157
|
+
with log.open("ab") as out:
|
|
158
|
+
# New session: the hub must outlive this terminal.
|
|
159
|
+
child = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=out, stderr=out, start_new_session=True)
|
|
160
|
+
_wait_until_up(mesh, list(nodes), child)
|
|
161
|
+
_print_hints(mesh, len(nodes))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@mesh.command("down")
|
|
165
|
+
@mesh_option
|
|
166
|
+
def mesh_down(mesh: str) -> None:
|
|
167
|
+
"""Stop the hub and kill the mesh's tmux session and all its panes."""
|
|
168
|
+
stopped = False
|
|
169
|
+
pid = _hub_pid(mesh)
|
|
170
|
+
if pid is not None:
|
|
171
|
+
os.kill(pid, signal.SIGTERM)
|
|
172
|
+
deadline = time.monotonic() + STOP_TIMEOUT
|
|
173
|
+
while _hub_pid(mesh) is not None and time.monotonic() < deadline:
|
|
174
|
+
time.sleep(0.1)
|
|
175
|
+
stopped = True
|
|
176
|
+
try:
|
|
177
|
+
TmuxMultiplexer(socket=tmux_socket(mesh)).kill_session(SessionRef(session_name(mesh)))
|
|
178
|
+
stopped = True
|
|
179
|
+
except MuxError:
|
|
180
|
+
pass
|
|
181
|
+
if not stopped:
|
|
182
|
+
raise click.ClickException(f"no running mesh {mesh!r}")
|
|
183
|
+
click.echo(f"mesh {mesh!r} down")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@mesh.command("attach")
|
|
187
|
+
@mesh_option
|
|
188
|
+
def mesh_attach(mesh: str) -> None:
|
|
189
|
+
"""Attach this terminal to the mesh's tmux session (detach with ctrl-b d)."""
|
|
190
|
+
sock, session = tmux_socket(mesh), session_name(mesh)
|
|
191
|
+
os.execvp("tmux", ["tmux", "-L", sock, "attach", "-t", f"={session}"])
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@mesh.command("logs")
|
|
195
|
+
@mesh_option
|
|
196
|
+
@click.option("-n", default=50, show_default=True, help="Number of lines.")
|
|
197
|
+
@click.option("-f", "follow", is_flag=True, help="Follow new lines.")
|
|
198
|
+
def mesh_logs(mesh: str, n: int, follow: bool) -> None:
|
|
199
|
+
"""Show the hub's log (state transitions, startup errors)."""
|
|
200
|
+
path = log_path(mesh)
|
|
201
|
+
if not path.exists():
|
|
202
|
+
raise click.ClickException(f"no hub log for mesh {mesh!r}")
|
|
203
|
+
if follow:
|
|
204
|
+
os.execvp("tail", ["tail", "-n", str(n), "-f", str(path)])
|
|
205
|
+
click.echo(_tail(path, n))
|