jambu-gpu 0.3.0__tar.gz → 0.4.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.
Files changed (104) hide show
  1. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/PKG-INFO +4 -1
  2. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/README.md +3 -0
  3. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/docs/ACCEPTANCE.md +16 -0
  4. jambu_gpu-0.4.0/docs/AGENTS.md +56 -0
  5. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/docs/CONFIG.md +2 -1
  6. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/docs/INTEGRATION.md +2 -1
  7. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/docs/USAGE.md +3 -0
  8. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/__init__.py +1 -1
  9. jambu_gpu-0.4.0/jambu_gpu/agent/__init__.py +1 -0
  10. jambu_gpu-0.4.0/jambu_gpu/agent/install.py +173 -0
  11. jambu_gpu-0.4.0/jambu_gpu/agent/jobs.py +193 -0
  12. jambu_gpu-0.4.0/jambu_gpu/agent/mcp.py +646 -0
  13. jambu_gpu-0.4.0/jambu_gpu/agent/skill/SKILL.md +68 -0
  14. jambu_gpu-0.4.0/jambu_gpu/cli/agent.py +74 -0
  15. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/cli/main.py +3 -0
  16. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/core/config.py +5 -0
  17. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/core/engine.py +37 -6
  18. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/core/errors.py +6 -0
  19. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/core/models.py +8 -0
  20. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/core/state.py +2 -2
  21. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/providers/base.py +4 -0
  22. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/providers/vast/mapper.py +18 -0
  23. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/providers/vast/provider.py +4 -0
  24. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/runtime/base.py +13 -1
  25. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/runtime/vllm.py +10 -0
  26. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/pyproject.toml +1 -1
  27. jambu_gpu-0.4.0/tests/test_agent.py +348 -0
  28. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/tests/test_engine.py +36 -0
  29. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/tests/test_runtime_vllm.py +13 -0
  30. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/tests/test_vast_mapper.py +35 -3
  31. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/tests/test_vast_provider.py +15 -0
  32. jambu_gpu-0.4.0/uv.lock +870 -0
  33. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/.env.example +0 -0
  34. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/.gitignore +0 -0
  35. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/CONTRIBUTING.md +0 -0
  36. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/LICENSE +0 -0
  37. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/docs/ADDING_A_PROVIDER.md +0 -0
  38. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/docs/CHOOSING_A_MODEL.md +0 -0
  39. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/docs/LIFECYCLE.md +0 -0
  40. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/docs/assets/jambu-logo.jpg +0 -0
  41. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/examples/experiment.py +0 -0
  42. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/examples/jambu.yaml +0 -0
  43. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/examples/mastra-agent/README.md +0 -0
  44. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/examples/mastra-agent/package-lock.json +0 -0
  45. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/examples/mastra-agent/package.json +0 -0
  46. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/examples/mastra-agent/src/agent.ts +0 -0
  47. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/examples/mastra-agent/src/index.ts +0 -0
  48. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/examples/mastra-agent/src/tools/weather-tool.ts +0 -0
  49. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/examples/mastra-agent/tsconfig.json +0 -0
  50. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/examples/typescript-client/README.md +0 -0
  51. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/examples/typescript-client/package-lock.json +0 -0
  52. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/examples/typescript-client/package.json +0 -0
  53. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/examples/typescript-client/src/index.ts +0 -0
  54. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/examples/typescript-client/src/jambuGpu.ts +0 -0
  55. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/examples/typescript-client/tsconfig.json +0 -0
  56. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu.example.yaml +0 -0
  57. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu.models.yaml +0 -0
  58. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu.yaml +0 -0
  59. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/__main__.py +0 -0
  60. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/cli/__init__.py +0 -0
  61. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/cli/_common.py +0 -0
  62. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/cli/account.py +0 -0
  63. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/cli/inspect.py +0 -0
  64. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/cli/lifecycle.py +0 -0
  65. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/cli/providers.py +0 -0
  66. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/cli/run.py +0 -0
  67. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/cli/setup.py +0 -0
  68. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/cli/status.py +0 -0
  69. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/cli/timer.py +0 -0
  70. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/cli/validate.py +0 -0
  71. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/core/__init__.py +0 -0
  72. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/core/beacon.py +0 -0
  73. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/core/credentials.py +0 -0
  74. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/core/durations.py +0 -0
  75. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/core/events.py +0 -0
  76. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/core/execution.py +0 -0
  77. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/core/health.py +0 -0
  78. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/core/hosts.py +0 -0
  79. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/core/model_inspect.py +0 -0
  80. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/core/paths.py +0 -0
  81. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/core/policy.py +0 -0
  82. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/core/sweep.py +0 -0
  83. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/core/tool_parsers.py +0 -0
  84. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/providers/__init__.py +0 -0
  85. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/providers/registry.py +0 -0
  86. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/providers/vast/__init__.py +0 -0
  87. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/providers/vast/client.py +0 -0
  88. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/providers/vast/config.py +0 -0
  89. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/runtime/__init__.py +0 -0
  90. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/watchdog/__init__.py +0 -0
  91. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/watchdog/agent_body.py +0 -0
  92. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/jambu_gpu/watchdog/builder.py +0 -0
  93. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/tests/conftest.py +0 -0
  94. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/tests/fakes.py +0 -0
  95. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/tests/test_cli.py +0 -0
  96. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/tests/test_config.py +0 -0
  97. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/tests/test_engine_sessions.py +0 -0
  98. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/tests/test_integration.py +0 -0
  99. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/tests/test_model_inspect.py +0 -0
  100. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/tests/test_paths.py +0 -0
  101. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/tests/test_policy.py +0 -0
  102. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/tests/test_state.py +0 -0
  103. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/tests/test_sweep.py +0 -0
  104. {jambu_gpu-0.3.0 → jambu_gpu-0.4.0}/tests/test_watchdog.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: jambu-gpu
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Provider-agnostic CLI for provisioning and managing temporary GPU runtimes
5
5
  Project-URL: Homepage, https://github.com/jambuai/jambu-gpu
6
6
  Project-URL: Repository, https://github.com/jambuai/jambu-gpu
@@ -47,6 +47,8 @@ When you are finished, `gpu destroy`. If you walk away, the idle timeout does it
47
47
 
48
48
  From an application in any language: `gpu --json setup` returns the OpenAI-compatible `base_url`, `api_key` and `model`, and the app uses any OpenAI SDK. See [Integrating gpu into an application](docs/INTEGRATION.md), with a ready TypeScript client.
49
49
 
50
+ From a coding agent: `gpu agent install` gives Claude Code (or `-t cursor`, `-t desktop`) a skill and local MCP tools to rent a GPU, run the experiment and shut it down. See [Letting a coding agent rent GPUs](docs/AGENTS.md).
51
+
50
52
  As a last line of defense, `gpu ls` shows every jambu instance in the account and `gpu sweep --install-timer` sets up a systemd user timer that guards every project on the machine by its own rules and cleans up the orphans no project tracks. See [Keeping the account clean](docs/USAGE.md#keeping-the-account-clean).
51
53
 
52
54
  The API key is read from the environment, a project `.env`, or `~/.gpu/credentials`. It never goes in the manifest.
@@ -69,6 +71,7 @@ The file is `jambu.yaml`. This tool reads only the `gpu_runtime:` key, so other
69
71
  ## Docs
70
72
 
71
73
  - [Integrating into an application (any language)](docs/INTEGRATION.md)
74
+ - [Letting a coding agent rent GPUs (Claude Code, Cursor, Claude Desktop)](docs/AGENTS.md)
72
75
  - [Commands, workloads, and agents](docs/USAGE.md)
73
76
  - [Every config field](docs/CONFIG.md)
74
77
  - [Choosing a model](docs/CHOOSING_A_MODEL.md)
@@ -24,6 +24,8 @@ When you are finished, `gpu destroy`. If you walk away, the idle timeout does it
24
24
 
25
25
  From an application in any language: `gpu --json setup` returns the OpenAI-compatible `base_url`, `api_key` and `model`, and the app uses any OpenAI SDK. See [Integrating gpu into an application](docs/INTEGRATION.md), with a ready TypeScript client.
26
26
 
27
+ From a coding agent: `gpu agent install` gives Claude Code (or `-t cursor`, `-t desktop`) a skill and local MCP tools to rent a GPU, run the experiment and shut it down. See [Letting a coding agent rent GPUs](docs/AGENTS.md).
28
+
27
29
  As a last line of defense, `gpu ls` shows every jambu instance in the account and `gpu sweep --install-timer` sets up a systemd user timer that guards every project on the machine by its own rules and cleans up the orphans no project tracks. See [Keeping the account clean](docs/USAGE.md#keeping-the-account-clean).
28
30
 
29
31
  The API key is read from the environment, a project `.env`, or `~/.gpu/credentials`. It never goes in the manifest.
@@ -46,6 +48,7 @@ The file is `jambu.yaml`. This tool reads only the `gpu_runtime:` key, so other
46
48
  ## Docs
47
49
 
48
50
  - [Integrating into an application (any language)](docs/INTEGRATION.md)
51
+ - [Letting a coding agent rent GPUs (Claude Code, Cursor, Claude Desktop)](docs/AGENTS.md)
49
52
  - [Commands, workloads, and agents](docs/USAGE.md)
50
53
  - [Every config field](docs/CONFIG.md)
51
54
  - [Choosing a model](docs/CHOOSING_A_MODEL.md)
@@ -57,6 +57,22 @@ Driven end to end by [`examples/typescript-client`](../examples/typescript-clien
57
57
 
58
58
  It found two more bugs, both fixed and tested: Vast reports a stopping instance as `running` for a few seconds, which made a setup right after a stop skip the restart; and a Vast rate limit (`429`) failed setup instead of being waited out.
59
59
 
60
+ ## A coding agent driving it (0.4.0)
61
+
62
+ On 2026-10-25 a headless Claude Code with no prior context (`claude -p`) was given a fresh directory holding only `gpu agent install`'s skill and `.mcp.json`, plus `VAST_API_KEY` in `.env`. It was asked in one sentence to test Qwen2.5-0.5B on a rented GPU, ask it the capital of Brazil, report time and cost, and destroy the machine. It loaded the `gpu` skill by itself and called `gpu_init` (with the cap and VRAM from the request), `gpu_validate`, `gpu_offers`, then `gpu_setup` until ready, `curl` for the question, `gpu_status`, `gpu_destroy` and `gpu_ls`. The model answered "Brasília", the account was empty at the end, and the agent reported setup time and cost correctly, noting that `session_cost_usd` leaves out the replaced host.
63
+
64
+ | What was checked | What happened |
65
+ | --- | --- |
66
+ | Setup longer than any tool timeout | `gpu_setup` returned `running` every 300 s for 42 minutes and re-attached to the same background job each time: one machine at a time, never a second setup. |
67
+ | Bad hosts | The first host could not pull the image for 30 minutes; setup destroyed it and rented another, which served. |
68
+ | Cancel | Over MCP (official Python SDK client), `gpu_destroy` during a setup interrupted it like Ctrl-C: the booting machine was destroyed and `gpu_ls` came back empty, 2.3 s after the call. |
69
+ | Protocol | The official MCP Python SDK (2.3.0) client initializes, lists the 13 tools and calls them. |
70
+
71
+ The first attempt found what the unit tests had not, each fixed and tested:
72
+ - during a setup, `gpu status`, `gpu logs`, `gpu stop` and `gpu destroy` failed on the project lock (reported as "no instance"). Now `status` and `logs` report the last saved state with `busy: true`, the MCP `gpu_stop` / `gpu_destroy` cancel the setup first, and a held lock is its own exit code, 12;
73
+ - the host that failed last when setup gave up was not remembered as bad, so the next setup rented it again;
74
+ - on Vast, three hosts in a row stalled 30 minutes each pulling the 12 GB Docker Hub image, and setup gave up after 90 minutes with nothing left billing. Vast now defaults to `vastai/vllm:v0.32.0-cuda-13.0`, the same vLLM that Vast's hosts cache: in the cancel test the container was running within a minute.
75
+
60
76
  ## The MVP command surface
61
77
 
62
78
  `jambu.yaml` schema, CLI, provider contract, provider registry, Vast.ai adapter,
@@ -0,0 +1,56 @@
1
+ # Letting a coding agent rent GPUs
2
+
3
+ `gpu` ships a skill and a local MCP server, so Claude Code, Cursor or Claude Desktop can rent a GPU, serve a model, run an experiment against it and shut it down, from a request like "compare Qwen 7B and Llama 8B on these 50 prompts".
4
+
5
+ The watchdog on the machine is what makes this safe to hand to an agent: a machine the agent forgets, or a conversation that ends mid-experiment, stops by itself after `lifecycle.idle_timeout`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pipx install jambu-gpu # or: uv tool install jambu-gpu
11
+ cd your-project
12
+ echo "VAST_API_KEY=..." >> .env # or ~/.gpu/credentials; never in jambu.yaml
13
+ gpu agent install # Claude Code
14
+ gpu agent install -t cursor # Cursor
15
+ gpu agent install -t desktop # Claude Desktop
16
+ ```
17
+
18
+ | Target | Skill | MCP server |
19
+ | --- | --- | --- |
20
+ | `claude` (default) | `.claude/skills/gpu/SKILL.md` | `.mcp.json` (Claude Code asks once to approve it) |
21
+ | `cursor` | the same `.claude/skills/gpu/` (Cursor reads Claude's skill directories) | `.cursor/mcp.json`, with `--project ${workspaceFolder}` |
22
+ | `desktop` | `~/.gpu/gpu-skill.zip`, to upload under Settings > Capabilities > Skills | `claude_desktop_config.json`, pinned to this project |
23
+
24
+ `--global` installs for every project of the user instead: the skill in `~/.claude/skills/gpu/`, the MCP server through `claude mcp add --scope user` and in `~/.cursor/mcp.json`. Existing config files are merged; only the `gpu` entry is written. Re-running is safe.
25
+
26
+ Claude Desktop and Cursor do not get your shell's environment. Keep `VAST_API_KEY` in the project `.env` or `~/.gpu/credentials`, not only in an `export`.
27
+
28
+ Another agent that reads `SKILL.md` files: `gpu agent skill > path/to/skills/gpu/SKILL.md`. Any MCP client: run `gpu mcp --project /path/to/project` over stdio.
29
+
30
+ ## The MCP tools
31
+
32
+ | Tool | Does | Costs money |
33
+ | --- | --- | --- |
34
+ | `gpu_init` | write `jambu.yaml` for a model, with an optional price cap and VRAM | no |
35
+ | `gpu_validate` | check the config and credentials | no |
36
+ | `gpu_offers` | cheapest matching machines and their price | no |
37
+ | `gpu_setup` | rent / restart and wait for the model; returns `base_url`, `api_key`, `model` | yes |
38
+ | `gpu_status` | state, cost so far, idle time, the watchdog's next move, setup progress | no |
39
+ | `gpu_connection` | `base_url`, `api_key`, `model` without starting anything | no |
40
+ | `gpu_run` | run a local command with `OPENAI_BASE_URL` / `OPENAI_API_KEY` / `JAMBU_MODEL_ID` set | yes, if it has to start the machine |
41
+ | `gpu_logs` | runtime, onstart, watchdog or provider log | no |
42
+ | `gpu_lock` / `gpu_unlock` | keep the machine up through a quiet period | while locked |
43
+ | `gpu_stop` | stop now; disk kept, restart takes about a minute | ends billing for compute |
44
+ | `gpu_destroy` | delete; needs `confirm=true` | ends all billing |
45
+ | `gpu_ls` | every machine in the account, from any project | no |
46
+
47
+ Each tool runs the `gpu` CLI with `--json` in the project directory, so errors carry the same `exit_code` as the [application contract](INTEGRATION.md#exit-codes), plus `meaning` and `next` for the agent. A tool takes an optional `project_dir`; by default it is the server's `--project`, `$GPU_PROJECT_DIR`, or the directory the client started it in.
48
+
49
+ `gpu_setup` would outlast any client's tool timeout on a first rent (5–15 minutes). It starts setup as a background job, waits up to `wait_seconds` (45 by default), and reports `status: "running"` until the model answers. Calling it again re-attaches to the same job; it never starts a second machine. The job outlives the MCP server, so a client restarted mid-setup finds it again through `gpu_setup` or `gpu_status`, which answers during setup with `"busy": true` and the job's progress. `gpu_stop` and `gpu_destroy` cancel a setup in progress the way Ctrl-C does: the machine still booting is destroyed.
50
+
51
+ ## What keeps the agent in line
52
+
53
+ - **The skill** tells the agent to show the price and ask before renting, wait out a slow setup instead of starting over, never touch `VAST_API_KEY`, and offer stop or destroy (and check `gpu_ls`) when done.
54
+ - **The server's tool annotations** mark `gpu_destroy` destructive and the read-only tools read-only, so a client can let `gpu_status` run freely and ask for every `gpu_setup` and `gpu_destroy`. In Claude Code that is the `permissions` list in `.claude/settings.json`, e.g. `"allow": ["mcp__gpu__gpu_status", "mcp__gpu__gpu_offers", "mcp__gpu__gpu_ls"]`.
55
+ - **`jambu.yaml`** holds the hard limits the agent cannot exceed without editing the file: `budget.max_hourly_cost_usd`, `budget.max_session_cost_usd`, `lifecycle.max_lifetime`.
56
+ - **The watchdog** stops the machine after `idle_timeout` whatever the agent does, and `gpu sweep --install-timer` catches anything left behind.
@@ -86,11 +86,12 @@ each number comes from.
86
86
  | `gpu.count` | `1` | Number of GPUs on one machine. |
87
87
  | `gpu.name_filter` | – | Substring match, e.g. `A100`, `RTX 4090`. |
88
88
  | `gpu.cuda_min` | – | Minimum host CUDA version. `gpu` raises it to what the runtime image needs, read from its tag (`-cu129` means 12.9, vLLM's bare `v0.32+` and `latest` mean 13.0), so an image never lands on a driver too old to run it. |
89
+ | `gpu.compute_cap_min` | – | Minimum GPU **compute capability** (architecture), e.g. `8.0` for Ampere. Distinct from `cuda_min`: a Pascal (GTX 10xx, 6.1) or Volta (V100, 7.0) card can sit behind a modern CUDA-13 driver — clearing `cuda_min` — yet still be too old for a runtime image whose kernels are built for newer architectures, which then crashes at engine startup. `gpu` raises this to what the runtime needs: a CUDA-13 vLLM image floors it at `8.0` (those wheels drop Volta), a CUDA-12 vLLM image at `7.0`. The cheapest Vast offers are often exactly these old cards, so without the floor the search keeps picking a GPU the image cannot run. |
89
90
  | `disk_gb` | `80` | Disk. Must fit the model weights plus the container. |
90
91
  | `region` | – | Comma-separated geolocations. |
91
92
  | `spot` | `false` | Use interruptible/bid capacity where supported. |
92
93
  | `host_attempts` | `3` | How many machines `setup` tries. A host that fails to boot (the provider reports it failed, it vanishes, or it is not running within `health.boot_timeout`) is destroyed, replaced from fresh offers (each held to the budget), and remembered in `$GPU_HOME/bad_hosts.json` so no setup rents it again for 24h. A model server that dies once its container runs fails at once, since the same config would fail on any machine, unless its log shows the host's network failed it (a dropped connection or timeout while downloading weights): that counts as a host failure too. Needs `lifecycle.cleanup_on_setup_failure`; without it the first failed host is kept for inspection. |
93
- | `image` | – | Override the runtime's container image. vLLM's default is `vllm/vllm-openai:v0.32.0`. Pin a tag: `validate` warns on `latest` or an untagged image, because the CUDA it needs can change under you. On Vast, `vastai/vllm:v0.32.0-cuda-13.0` (the same vLLM, packaged by Vast) is often cached on the host and boots in minutes where a 12 GB Docker Hub pull can stall. |
94
+ | `image` | – | Override the runtime's container image. vLLM's default is `vllm/vllm-openai:v0.32.0`, except on Vast, where it is `vastai/vllm:v0.32.0-cuda-13.0`. Pin a tag: `validate` warns on `latest` or an untagged image, because the CUDA it needs can change under you. The Vast default (the same vLLM, packaged by Vast) is cached on many hosts, where the 12 GB Docker Hub image often stalled past `health.boot_timeout`. |
94
95
 
95
96
  ---
96
97
 
@@ -111,6 +111,7 @@ gpu env --format dotenv > .env.gpu
111
111
  | 9 | the provider cannot do this | change the config |
112
112
  | 10 | over `budget.max_hourly_cost_usd` | raise the budget or wait for cheaper offers |
113
113
  | 11 | the model server did not start (setup failed, or every host tried failed to boot) | read stderr: it carries the server's log tail; the machine was destroyed |
114
+ | 12 | another `gpu` command holds this project's lock (usually a `setup` still booting) | wait and retry; `status` and `logs` do not fail on it, they report the last saved state with `"busy": true` |
114
115
 
115
116
  ### Lifecycle the app has to expect
116
117
 
@@ -226,7 +227,7 @@ Run `gpu --json setup` as a subprocess, parse the JSON, and point the language's
226
227
  | Symptom | Look at |
227
228
  | --- | --- |
228
229
  | `setup` fails with exit 11 | stderr has the model server's last log lines; `gpu logs --source runtime` |
229
- | `setup` slow on the first call | `gpu logs --source onstart`; on Vast, `compute.image: vastai/vllm:v0.32.0-cuda-13.0` pulls faster than Docker Hub's `vllm/vllm-openai` |
230
+ | `setup` slow on the first call | `gpu status` (works during setup) and `gpu logs --source onstart`. On Vast the default image is `vastai/vllm`, which most hosts already cache; a project that pins `compute.image: vllm/vllm-openai:...` pulls 12 GB from Docker Hub |
230
231
  | `401` from the endpoint | the key changed (a new machine was rented): take it from a fresh `setup` |
231
232
  | the machine stopped while the app was busy | `gpu events`; check `idle_timeout` against the longest silence between requests, or `gpu lock` |
232
233
  | unsure what is still billing | `gpu ls` lists every jambu instance in the account |
@@ -27,6 +27,9 @@ Operator reference. The project front page is the [README](../README.md). Field-
27
27
  | `gpu sweep --install-timer` | Write a systemd user timer running `sweep --apply`, and register this project |
28
28
  | `gpu endpoint` | Print the model endpoint URL |
29
29
  | `gpu env` | Print the base URL, API key and model as shell exports (`--format dotenv\|json`), without starting anything |
30
+ | `gpu agent install` | Give Claude Code (`-t claude`), Cursor (`-t cursor`) or Claude Desktop (`-t desktop`) the gpu skill and MCP server ([AGENTS.md](AGENTS.md)) |
31
+ | `gpu agent skill` | Print the bundled `SKILL.md` |
32
+ | `gpu mcp` | The local MCP server on stdio, started by the agent's client |
30
33
 
31
34
  Every command accepts `--json` and `--config PATH`. To call `gpu` from an application in any language, see [INTEGRATION.md](INTEGRATION.md).
32
35
 
@@ -1,5 +1,5 @@
1
1
  """gpu: provider-agnostic GPU runtime provisioning and lifecycle CLI."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -0,0 +1 @@
1
+ """Agent integrations: the local MCP server, the bundled skill and their installer."""
@@ -0,0 +1,173 @@
1
+ """`gpu agent install`: put the skill and the MCP server where agents look for them.
2
+
3
+ One skill copy, in ``.claude/skills/gpu/`` (or ``~/.claude/skills/gpu/``),
4
+ serves both Claude Code and Cursor, which also reads Claude's skill
5
+ directories. The MCP server is registered per client:
6
+
7
+ - claude: ``.mcp.json`` in the project; with --global, ``claude mcp add -s user``
8
+ - cursor: ``.cursor/mcp.json`` in the project, or ``~/.cursor/mcp.json``
9
+ - desktop: Claude Desktop's ``claude_desktop_config.json``, pinned to this
10
+ project, plus a zip of the skill to upload in Settings
11
+
12
+ Existing config files are merged, never replaced: only the ``gpu`` server
13
+ entry is written.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import platform
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ import zipfile
25
+ from pathlib import Path
26
+ from typing import Any, Optional
27
+
28
+ from ..core.errors import ConfigError
29
+ from ..core.paths import gpu_home
30
+
31
+ SERVER_NAME = "gpu"
32
+ SKILL_NAME = "gpu"
33
+ TARGETS = ("claude", "cursor", "desktop")
34
+
35
+
36
+ def skill_source() -> Path:
37
+ return Path(__file__).parent / "skill" / "SKILL.md"
38
+
39
+
40
+ def skill_text() -> str:
41
+ return skill_source().read_text()
42
+
43
+
44
+ def gpu_command(portable: bool) -> list[str]:
45
+ """How a client starts `gpu`.
46
+
47
+ A project file that may be committed says plain ``gpu`` when it is on
48
+ PATH; anything machine-local uses an absolute path, because GUI apps
49
+ (Claude Desktop, Cursor) do not get the shell's PATH.
50
+ """
51
+ found = shutil.which("gpu")
52
+ if found and portable:
53
+ return ["gpu"]
54
+ if found:
55
+ return [str(Path(found).resolve())]
56
+ return [sys.executable, "-m", "jambu_gpu"]
57
+
58
+
59
+ def desktop_config_path() -> Path:
60
+ system = platform.system()
61
+ if system == "Darwin":
62
+ return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
63
+ if system == "Windows":
64
+ return Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) / "Claude" / "claude_desktop_config.json"
65
+ base = os.environ.get("XDG_CONFIG_HOME")
66
+ return (Path(base) if base else Path.home() / ".config") / "Claude" / "claude_desktop_config.json"
67
+
68
+
69
+ def merge_server(path: Path, entry: dict[str, Any]) -> str:
70
+ """Write ``mcpServers.gpu`` into a JSON config. Returns created/updated/unchanged."""
71
+ data: dict[str, Any] = {}
72
+ if path.is_file():
73
+ text = path.read_text().strip()
74
+ if text:
75
+ try:
76
+ data = json.loads(text)
77
+ except ValueError as exc:
78
+ raise ConfigError(f"{path} is not valid JSON ({exc}); fix it or add the gpu server by hand") from exc
79
+ if not isinstance(data, dict):
80
+ raise ConfigError(f"{path} does not hold a JSON object")
81
+ servers = data.setdefault("mcpServers", {})
82
+ if not isinstance(servers, dict):
83
+ raise ConfigError(f"{path}: mcpServers is not an object")
84
+ if servers.get(SERVER_NAME) == entry:
85
+ return "unchanged"
86
+ action = "updated" if SERVER_NAME in servers else "created"
87
+ servers[SERVER_NAME] = entry
88
+ path.parent.mkdir(parents=True, exist_ok=True)
89
+ path.write_text(json.dumps(data, indent=2) + "\n")
90
+ return action
91
+
92
+
93
+ def write_skill(directory: Path) -> str:
94
+ target = directory / SKILL_NAME / "SKILL.md"
95
+ text = skill_text()
96
+ if target.is_file() and target.read_text() == text:
97
+ return "unchanged"
98
+ action = "updated" if target.exists() else "created"
99
+ target.parent.mkdir(parents=True, exist_ok=True)
100
+ target.write_text(text)
101
+ return action
102
+
103
+
104
+ def write_skill_zip(path: Path) -> str:
105
+ """The skill as Claude Desktop / claude.ai expect an upload: a folder in a zip."""
106
+ path.parent.mkdir(parents=True, exist_ok=True)
107
+ with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
108
+ archive.writestr(f"{SKILL_NAME}/SKILL.md", skill_text())
109
+ return "created"
110
+
111
+
112
+ def install(
113
+ targets: list[str],
114
+ project_dir: Path,
115
+ global_scope: bool = False,
116
+ run=subprocess.run,
117
+ ) -> dict[str, Any]:
118
+ """Install for each target. ``run`` is a seam for tests (``claude mcp add``)."""
119
+ unknown = [t for t in targets if t not in TARGETS]
120
+ if unknown:
121
+ raise ConfigError(f"unknown target {', '.join(unknown)} (choose from {', '.join(TARGETS)})")
122
+ project_dir = project_dir.resolve()
123
+ done: list[dict[str, Any]] = []
124
+ notes: list[str] = []
125
+
126
+ if "claude" in targets or "cursor" in targets:
127
+ skills = (Path.home() if global_scope else project_dir) / ".claude" / "skills"
128
+ done.append({"what": "skill (Claude Code, Cursor)", "path": str(skills / SKILL_NAME / "SKILL.md"), "action": write_skill(skills)})
129
+
130
+ if "claude" in targets:
131
+ if global_scope:
132
+ command = gpu_command(portable=False)
133
+ argv = ["claude", "mcp", "add", "--scope", "user", SERVER_NAME, "--", *command, "mcp"]
134
+ if shutil.which("claude"):
135
+ # `add` refuses an existing name; replace it so a reinstall picks up a new path.
136
+ run(["claude", "mcp", "remove", "--scope", "user", SERVER_NAME], capture_output=True, text=True)
137
+ proc = run(argv, capture_output=True, text=True)
138
+ if proc.returncode != 0:
139
+ raise ConfigError(f"`{' '.join(argv)}` failed: {(proc.stderr or proc.stdout).strip()}")
140
+ done.append({"what": "MCP server (Claude Code, user scope)", "path": "claude mcp", "action": "created"})
141
+ else:
142
+ notes.append(f"claude CLI not found; register the server with: {' '.join(argv)}")
143
+ else:
144
+ command = gpu_command(portable=True)
145
+ entry = {"command": command[0], "args": [*command[1:], "mcp"]}
146
+ path = project_dir / ".mcp.json"
147
+ done.append({"what": "MCP server (Claude Code)", "path": str(path), "action": merge_server(path, entry)})
148
+ notes.append("Claude Code asks once to approve the project's .mcp.json servers")
149
+
150
+ if "cursor" in targets:
151
+ command = gpu_command(portable=not global_scope)
152
+ entry = {
153
+ "type": "stdio",
154
+ "command": command[0],
155
+ "args": [*command[1:], "mcp", "--project", "${workspaceFolder}"],
156
+ }
157
+ path = (Path.home() if global_scope else project_dir) / ".cursor" / "mcp.json"
158
+ done.append({"what": "MCP server (Cursor)", "path": str(path), "action": merge_server(path, entry)})
159
+
160
+ if "desktop" in targets:
161
+ command = gpu_command(portable=False)
162
+ entry = {"command": command[0], "args": [*command[1:], "mcp", "--project", str(project_dir)]}
163
+ path = desktop_config_path()
164
+ done.append({"what": "MCP server (Claude Desktop)", "path": str(path), "action": merge_server(path, entry)})
165
+ archive = gpu_home() / "gpu-skill.zip"
166
+ done.append({"what": "skill zip (Claude Desktop)", "path": str(archive), "action": write_skill_zip(archive)})
167
+ notes.append(f"Claude Desktop: restart it, then upload {archive} under Settings > Capabilities > Skills")
168
+ notes.append(
169
+ "Claude Desktop does not get your shell's environment: keep VAST_API_KEY in the "
170
+ "project .env or ~/.gpu/credentials"
171
+ )
172
+
173
+ return {"installed": done, "notes": notes}
@@ -0,0 +1,193 @@
1
+ """`gpu setup` in the background, for callers that cannot block for 15 minutes.
2
+
3
+ An MCP tool call times out long before a first setup finishes, so the MCP
4
+ server starts setup as a detached job and reports on it when asked again.
5
+ The job outlives the server: a client that disconnects mid-setup leaves a
6
+ machine that either finishes booting (and is then stopped by its watchdog
7
+ when nobody uses it) or is destroyed by setup's own failure cleanup.
8
+
9
+ Files, under the project's ``.gpu/jobs/``:
10
+
11
+ - ``setup.json`` pid, argv and start time
12
+ - ``setup.out`` the command's stdout (one JSON document with --json)
13
+ - ``setup.err`` its stderr (progress is suppressed; errors land here)
14
+ - ``setup.rc`` the exit code, written last, when the command ends
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import signal
22
+ import subprocess
23
+ import sys
24
+ import time
25
+ from pathlib import Path
26
+ from typing import Any, Optional, Sequence
27
+
28
+ from ..core.paths import project_state_dir
29
+
30
+ JOB_DIRNAME = "jobs"
31
+ ERROR_TAIL_LINES = 40
32
+
33
+
34
+ def _alive(pid: int) -> bool:
35
+ if os.name == "nt": # signal 0 is CTRL_C_EVENT on Windows; trust the rc file
36
+ return True
37
+ try:
38
+ os.kill(pid, 0)
39
+ except ProcessLookupError:
40
+ return False
41
+ except PermissionError:
42
+ return True
43
+ return True
44
+
45
+
46
+ def _tail(path: Path, lines: int) -> list[str]:
47
+ try:
48
+ text = path.read_text(errors="replace")
49
+ except OSError:
50
+ return []
51
+ return [line for line in text.splitlines() if line.strip()][-lines:]
52
+
53
+
54
+ class Job:
55
+ """One named background command per project (only ``setup`` today)."""
56
+
57
+ def __init__(self, project_dir: Path, name: str = "setup") -> None:
58
+ self.dir = project_state_dir(Path(project_dir)) / JOB_DIRNAME
59
+ self.name = name
60
+ self.meta_path = self.dir / f"{name}.json"
61
+ self.out_path = self.dir / f"{name}.out"
62
+ self.err_path = self.dir / f"{name}.err"
63
+ self.rc_path = self.dir / f"{name}.rc"
64
+ self._proc: Optional[subprocess.Popen] = None
65
+
66
+ def meta(self) -> Optional[dict[str, Any]]:
67
+ try:
68
+ return json.loads(self.meta_path.read_text())
69
+ except (OSError, ValueError):
70
+ return None
71
+
72
+ def exit_code(self) -> Optional[int]:
73
+ try:
74
+ return int(self.rc_path.read_text().strip())
75
+ except (OSError, ValueError):
76
+ return None
77
+
78
+ def running(self) -> bool:
79
+ meta = self.meta()
80
+ if meta is None or self.exit_code() is not None:
81
+ return False
82
+ if self._proc is not None and self._proc.poll() is not None:
83
+ return False # reaped here: our own child never lingers as a zombie
84
+ return _alive(int(meta["pid"]))
85
+
86
+ def start(self, argv: Sequence[str], cwd: Path) -> dict[str, Any]:
87
+ """Start ``argv`` detached. The caller checks ``running()`` first."""
88
+ self.dir.mkdir(parents=True, exist_ok=True)
89
+ for path in (self.out_path, self.err_path, self.rc_path):
90
+ path.unlink(missing_ok=True)
91
+ runner = [sys.executable, "-m", "jambu_gpu.agent.jobs", str(self.dir), self.name, "--", *argv]
92
+ kwargs: dict[str, Any] = {}
93
+ if os.name == "nt":
94
+ kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
95
+ subprocess, "CREATE_NEW_PROCESS_GROUP", 0
96
+ )
97
+ else:
98
+ kwargs["start_new_session"] = True
99
+ self._proc = subprocess.Popen(
100
+ runner,
101
+ cwd=str(cwd),
102
+ stdin=subprocess.DEVNULL,
103
+ stdout=subprocess.DEVNULL,
104
+ stderr=subprocess.DEVNULL,
105
+ **kwargs,
106
+ )
107
+ meta = {"pid": self._proc.pid, "argv": list(argv), "started_at": time.time()}
108
+ self.meta_path.write_text(json.dumps(meta))
109
+ return meta
110
+
111
+ def wait(self, seconds: float, poll: float = 2.0) -> bool:
112
+ """Wait up to ``seconds`` for the job to end. True when it has."""
113
+ deadline = time.monotonic() + max(0.0, seconds)
114
+ while self.running():
115
+ if time.monotonic() >= deadline:
116
+ return False
117
+ time.sleep(min(poll, max(0.05, deadline - time.monotonic())))
118
+ return True
119
+
120
+ def cancel(self, timeout: float = 120.0) -> bool:
121
+ """Interrupt a running job like Ctrl-C and wait for it to clean up.
122
+
123
+ ``gpu setup`` destroys the machine it was booting when interrupted, so
124
+ a cancelled setup leaves nothing billing. True when the job has ended.
125
+ """
126
+ meta = self.meta()
127
+ if meta is None or not self.running():
128
+ return True
129
+ pid = int(meta["pid"])
130
+ try:
131
+ if os.name == "nt":
132
+ os.kill(pid, signal.CTRL_BREAK_EVENT)
133
+ else:
134
+ os.killpg(pid, signal.SIGINT) # the runner leads the group (start_new_session)
135
+ except (ProcessLookupError, PermissionError, OSError):
136
+ return not self.running()
137
+ return self.wait(timeout, poll=1.0)
138
+
139
+ def report(self) -> dict[str, Any]:
140
+ """``status`` is none, running, succeeded, failed or lost (died without a code)."""
141
+ meta = self.meta()
142
+ if meta is None:
143
+ return {"status": "none"}
144
+ started = float(meta.get("started_at") or 0)
145
+ report: dict[str, Any] = {
146
+ "pid": meta.get("pid"),
147
+ "started_at": started,
148
+ "elapsed_s": round(time.time() - started, 1) if started else None,
149
+ }
150
+ if self.running():
151
+ report["status"] = "running"
152
+ return report
153
+ code = self.exit_code()
154
+ if code is None:
155
+ report["status"] = "lost"
156
+ else:
157
+ report["status"] = "succeeded" if code == 0 else "failed"
158
+ report["exit_code"] = code
159
+ report["finished_at"] = self.rc_path.stat().st_mtime
160
+ report["elapsed_s"] = round(report["finished_at"] - started, 1) if started else None
161
+ try:
162
+ report["result"] = json.loads(self.out_path.read_text())
163
+ except (OSError, ValueError):
164
+ pass
165
+ if report["status"] != "succeeded":
166
+ report["stderr_tail"] = _tail(self.err_path, ERROR_TAIL_LINES)
167
+ return report
168
+
169
+
170
+ def _main(argv: list[str]) -> int:
171
+ """``python -m jambu_gpu.agent.jobs DIR NAME -- CMD...``: run CMD, record its exit code."""
172
+ directory, name = Path(argv[0]), argv[1]
173
+ command = argv[3:] if len(argv) > 2 and argv[2] == "--" else argv[2:]
174
+ # A cancel sends SIGINT to the whole group. The runner must outlive it to
175
+ # record the exit code, and must not kill the command while it cleans up.
176
+ # A Python handler (not SIG_IGN) resets to the default in the child on exec.
177
+ signal.signal(signal.SIGINT, lambda signum, frame: None)
178
+ with open(directory / f"{name}.out", "wb") as out, open(directory / f"{name}.err", "wb") as err:
179
+ try:
180
+ proc = subprocess.Popen(command, stdout=out, stderr=err, stdin=subprocess.DEVNULL)
181
+ except OSError as exc:
182
+ err.write(f"error: could not start {command[0]}: {exc}\n".encode())
183
+ code = 127
184
+ else:
185
+ code = proc.wait()
186
+ tmp = directory / f"{name}.rc.tmp"
187
+ tmp.write_text(str(code))
188
+ tmp.replace(directory / f"{name}.rc")
189
+ return code
190
+
191
+
192
+ if __name__ == "__main__":
193
+ sys.exit(_main(sys.argv[1:]))