jarvix 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-0.1.0/.github/workflows/publish.yml +45 -0
- jarvix-0.1.0/.gitignore +30 -0
- jarvix-0.1.0/.python-version +1 -0
- jarvix-0.1.0/LICENSE +21 -0
- jarvix-0.1.0/Makefile +42 -0
- jarvix-0.1.0/PKG-INFO +67 -0
- jarvix-0.1.0/README.md +34 -0
- jarvix-0.1.0/packages/cli/LICENSE +21 -0
- jarvix-0.1.0/packages/cli/README.md +0 -0
- jarvix-0.1.0/packages/cli/pyproject.toml +46 -0
- jarvix-0.1.0/packages/cli/src/jarvix/cli/__init__.py +86 -0
- jarvix-0.1.0/packages/cli/src/jarvix/cli/__main__.py +4 -0
- jarvix-0.1.0/packages/cli/src/jarvix/cli/launch.py +67 -0
- jarvix-0.1.0/packages/cli/src/jarvix/cli/mesh.py +205 -0
- jarvix-0.1.0/packages/core/LICENSE +21 -0
- jarvix-0.1.0/packages/core/Makefile +34 -0
- jarvix-0.1.0/packages/core/README.md +1 -0
- jarvix-0.1.0/packages/core/pyproject.toml +33 -0
- jarvix-0.1.0/packages/core/src/jarvix/core/__init__.py +2 -0
- jarvix-0.1.0/packages/core/src/jarvix/core/graph/__init__.py +3 -0
- jarvix-0.1.0/packages/core/src/jarvix/core/graph/base.py +175 -0
- jarvix-0.1.0/packages/core/src/jarvix/core/graph/impl.py +402 -0
- jarvix-0.1.0/packages/core/tests/conftest.py +50 -0
- jarvix-0.1.0/packages/core/tests/core/graph/helpers.py +62 -0
- jarvix-0.1.0/packages/core/tests/core/graph/test_branch.py +89 -0
- jarvix-0.1.0/packages/core/tests/core/graph/test_combine.py +82 -0
- jarvix-0.1.0/packages/core/tests/core/graph/test_conditional.py +200 -0
- jarvix-0.1.0/packages/core/tests/core/graph/test_graph_cache.py +148 -0
- jarvix-0.1.0/packages/core/tests/core/graph/test_graph_operands.py +160 -0
- jarvix-0.1.0/packages/core/tests/core/graph/test_graph_showcase.py +55 -0
- jarvix-0.1.0/packages/core/tests/core/graph/test_loops.py +46 -0
- jarvix-0.1.0/packages/core/tests/core/graph/test_rshift.py +232 -0
- jarvix-0.1.0/packages/core/tests/core/test_imports.py +19 -0
- jarvix-0.1.0/packages/hub/LICENSE +21 -0
- jarvix-0.1.0/packages/hub/Makefile +34 -0
- jarvix-0.1.0/packages/hub/README.md +47 -0
- jarvix-0.1.0/packages/hub/pyproject.toml +38 -0
- jarvix-0.1.0/packages/hub/src/jarvix/hub/__init__.py +23 -0
- jarvix-0.1.0/packages/hub/src/jarvix/hub/client.py +23 -0
- jarvix-0.1.0/packages/hub/src/jarvix/hub/hooks.py +15 -0
- jarvix-0.1.0/packages/hub/src/jarvix/hub/mux.py +11 -0
- jarvix-0.1.0/packages/hub/src/jarvix/hub/paths.py +25 -0
- jarvix-0.1.0/packages/hub/src/jarvix/hub/screen.py +10 -0
- jarvix-0.1.0/packages/hub/src/jarvix/hub/server.py +179 -0
- jarvix-0.1.0/packages/hub/src/jarvix/hub/state.py +79 -0
- jarvix-0.1.0/packages/hub/src/jarvix/hub/store.py +135 -0
- jarvix-0.1.0/packages/hub/tests/conftest.py +27 -0
- jarvix-0.1.0/packages/hub/tests/test_hub.py +187 -0
- jarvix-0.1.0/packages/hub/tests/test_state.py +64 -0
- jarvix-0.1.0/packages/mux/LICENSE +21 -0
- jarvix-0.1.0/packages/mux/Makefile +34 -0
- jarvix-0.1.0/packages/mux/README.md +42 -0
- jarvix-0.1.0/packages/mux/examples/demo.py +104 -0
- jarvix-0.1.0/packages/mux/pyproject.toml +34 -0
- jarvix-0.1.0/packages/mux/src/jarvix/mux/__init__.py +17 -0
- jarvix-0.1.0/packages/mux/src/jarvix/mux/_grid.py +54 -0
- jarvix-0.1.0/packages/mux/src/jarvix/mux/base.py +78 -0
- jarvix-0.1.0/packages/mux/src/jarvix/mux/exceptions.py +14 -0
- jarvix-0.1.0/packages/mux/src/jarvix/mux/tmux.py +259 -0
- jarvix-0.1.0/packages/mux/tests/mux/test_grid.py +48 -0
- jarvix-0.1.0/packages/mux/tests/mux/test_imports.py +26 -0
- jarvix-0.1.0/pyproject.toml +66 -0
- jarvix-0.1.0/uv.lock +190 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: astral-sh/setup-uv@v5
|
|
13
|
+
- name: Check tag matches package versions
|
|
14
|
+
run: |
|
|
15
|
+
tag="${GITHUB_REF_NAME#v}"
|
|
16
|
+
status=0
|
|
17
|
+
for f in pyproject.toml packages/*/pyproject.toml; do
|
|
18
|
+
version=$(python3 -c "import sys, tomllib; print(tomllib.load(open(sys.argv[1], 'rb'))['project']['version'])" "$f")
|
|
19
|
+
if [ "$version" != "$tag" ]; then
|
|
20
|
+
echo "::error file=$f::version $version does not match tag $GITHUB_REF_NAME"
|
|
21
|
+
status=1
|
|
22
|
+
fi
|
|
23
|
+
done
|
|
24
|
+
exit $status
|
|
25
|
+
- run: uv sync --all-packages
|
|
26
|
+
- run: uv run pytest
|
|
27
|
+
- run: uv build --all-packages
|
|
28
|
+
- uses: actions/upload-artifact@v4
|
|
29
|
+
with:
|
|
30
|
+
name: dist
|
|
31
|
+
path: dist/
|
|
32
|
+
|
|
33
|
+
publish:
|
|
34
|
+
needs: build
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
environment: pypi
|
|
37
|
+
permissions:
|
|
38
|
+
id-token: write
|
|
39
|
+
steps:
|
|
40
|
+
- uses: actions/download-artifact@v4
|
|
41
|
+
with:
|
|
42
|
+
name: dist
|
|
43
|
+
path: dist/
|
|
44
|
+
- uses: astral-sh/setup-uv@v5
|
|
45
|
+
- run: uv publish --trusted-publishing always dist/*
|
jarvix-0.1.0/.gitignore
ADDED
|
@@ -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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
jarvix-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.
|
jarvix-0.1.0/Makefile
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
APP_NAME := `sed -n 's/^ *name.*=.*"\([^"]*\)".*/\1/p' pyproject.toml`
|
|
2
|
+
APP_VERSION := `sed -n 's/^ *version.*=.*"\([^"]*\)".*/\1/p' pyproject.toml`
|
|
3
|
+
|
|
4
|
+
# Makefile help
|
|
5
|
+
|
|
6
|
+
.PHONY: header
|
|
7
|
+
header:
|
|
8
|
+
@printf "\033[34mEnvironment\033[0m\n"
|
|
9
|
+
@printf "\033[34m---------------------------------------------------------------\033[0m\n"
|
|
10
|
+
@printf "\033[33mAPP_NAME: \033[0m"
|
|
11
|
+
@printf "\033[35m$(APP_NAME)\033[0m\n"
|
|
12
|
+
@printf "\033[33mAPP_VERSION: \033[0m"
|
|
13
|
+
@printf "\033[35m$(APP_VERSION)\033[0m\n"
|
|
14
|
+
@printf "\n"
|
|
15
|
+
|
|
16
|
+
.PHONY: usage
|
|
17
|
+
usage:
|
|
18
|
+
@printf "\033[034mUsage\033[0m\n"
|
|
19
|
+
@printf "\033[34m---------------------------------------------------------------\033[0m\n"
|
|
20
|
+
@printf "\033[37mmake [options] \033[0m\n\n"
|
|
21
|
+
|
|
22
|
+
.PHONY: options
|
|
23
|
+
options:
|
|
24
|
+
@printf "\033[34mOptions\033[0m\n"
|
|
25
|
+
@printf "\033[34m---------------------------------------------------------------\033[0m\n"
|
|
26
|
+
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}' | sort
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
.PHONY: core
|
|
30
|
+
core: ## Run a command in packages/core/Makefile (Usage: make core <command>)
|
|
31
|
+
@$(MAKE) --no-print-directory -C packages/core $(filter-out $@, $(MAKECMDGOALS))
|
|
32
|
+
|
|
33
|
+
.PHONY: mux
|
|
34
|
+
mux: ## Run a command in packages/mux/Makefile (Usage: make mux <command>)
|
|
35
|
+
@$(MAKE) --no-print-directory -C packages/mux $(filter-out $@, $(MAKECMDGOALS))
|
|
36
|
+
|
|
37
|
+
.PHONY: test
|
|
38
|
+
test: ## Run tests (fast, offline)
|
|
39
|
+
uv run pytest
|
|
40
|
+
|
|
41
|
+
%:
|
|
42
|
+
@:
|
jarvix-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: jarvix
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Talk to your agent(s)
|
|
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: jarvix-core>=0.1.0
|
|
22
|
+
Provides-Extra: all
|
|
23
|
+
Requires-Dist: jarvix-cli>=0.1.0; extra == 'all'
|
|
24
|
+
Requires-Dist: jarvix-hub>=0.1.0; extra == 'all'
|
|
25
|
+
Requires-Dist: jarvix-mux>=0.1.0; extra == 'all'
|
|
26
|
+
Provides-Extra: cli
|
|
27
|
+
Requires-Dist: jarvix-cli>=0.1.0; extra == 'cli'
|
|
28
|
+
Provides-Extra: hub
|
|
29
|
+
Requires-Dist: jarvix-hub>=0.1.0; extra == 'hub'
|
|
30
|
+
Provides-Extra: mux
|
|
31
|
+
Requires-Dist: jarvix-mux>=0.1.0; extra == 'mux'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# Jarvix
|
|
35
|
+
|
|
36
|
+
**Talk to your agent(s).**
|
|
37
|
+
|
|
38
|
+
One coding agent is a great assistant. A dozen of them is a team, and a team needs a way to work together.
|
|
39
|
+
|
|
40
|
+
Jarvix turns a crowd of terminal agents into one coordinated crew. Launch them together, see what each one is doing, and stop juggling tabs to work out who is busy, who is stuck, and who needs you.
|
|
41
|
+
|
|
42
|
+
## Why Jarvix
|
|
43
|
+
|
|
44
|
+
- **See everything at a glance.** Every agent reports what it is doing, so you always know who is working, who is idle, and who is waiting on you.
|
|
45
|
+
- **Never miss a blocked agent.** When an agent needs your approval, it asks for your attention and you jump straight to it.
|
|
46
|
+
- **Shape your team.** Chain agents, fan work out in parallel, and route it conditionally, all in a few readable lines.
|
|
47
|
+
- **Stay in your terminal.** No new app to learn. Your agents live in plain tmux panes you can attach to and watch whenever you like.
|
|
48
|
+
- **Safe by design.** Each team runs on its own private tmux server, so your own sessions are never touched.
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
pip install "jarvix[cli]"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This installs the `jarvix` command. Requires Python 3.12+ and tmux.
|
|
57
|
+
|
|
58
|
+
Other extras: `jarvix[hub]`, `jarvix[mux]`, and `jarvix[all]`.
|
|
59
|
+
|
|
60
|
+
## Learn more
|
|
61
|
+
|
|
62
|
+
- [Mux](packages/mux/README.md) - spawning and arranging agent panes
|
|
63
|
+
- [Hub](packages/hub/README.md) - tracking what every agent is doing
|
|
64
|
+
|
|
65
|
+
## Status
|
|
66
|
+
|
|
67
|
+
Jarvix is in early development and moving fast.
|
jarvix-0.1.0/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Jarvix
|
|
2
|
+
|
|
3
|
+
**Talk to your agent(s).**
|
|
4
|
+
|
|
5
|
+
One coding agent is a great assistant. A dozen of them is a team, and a team needs a way to work together.
|
|
6
|
+
|
|
7
|
+
Jarvix turns a crowd of terminal agents into one coordinated crew. Launch them together, see what each one is doing, and stop juggling tabs to work out who is busy, who is stuck, and who needs you.
|
|
8
|
+
|
|
9
|
+
## Why Jarvix
|
|
10
|
+
|
|
11
|
+
- **See everything at a glance.** Every agent reports what it is doing, so you always know who is working, who is idle, and who is waiting on you.
|
|
12
|
+
- **Never miss a blocked agent.** When an agent needs your approval, it asks for your attention and you jump straight to it.
|
|
13
|
+
- **Shape your team.** Chain agents, fan work out in parallel, and route it conditionally, all in a few readable lines.
|
|
14
|
+
- **Stay in your terminal.** No new app to learn. Your agents live in plain tmux panes you can attach to and watch whenever you like.
|
|
15
|
+
- **Safe by design.** Each team runs on its own private tmux server, so your own sessions are never touched.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pip install "jarvix[cli]"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
This installs the `jarvix` command. Requires Python 3.12+ and tmux.
|
|
24
|
+
|
|
25
|
+
Other extras: `jarvix[hub]`, `jarvix[mux]`, and `jarvix[all]`.
|
|
26
|
+
|
|
27
|
+
## Learn more
|
|
28
|
+
|
|
29
|
+
- [Mux](packages/mux/README.md) - spawning and arranging agent panes
|
|
30
|
+
- [Hub](packages/hub/README.md) - tracking what every agent is doing
|
|
31
|
+
|
|
32
|
+
## Status
|
|
33
|
+
|
|
34
|
+
Jarvix is in early development and moving fast.
|
|
@@ -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.
|
|
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))
|