JerrettDavis Copilot commited on
Commit
ca89449
·
1 Parent(s): 97b851c

feat: add persistent install lifecycle management

Browse files

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

README.md CHANGED
@@ -92,6 +92,11 @@ PowerShell:
92
  irm https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.ps1 | iex
93
  ```
94
 
 
 
 
 
 
95
  ### Any agent — one function
96
 
97
  **Python:**
@@ -137,7 +142,7 @@ Use `cache` mode for long-running chats where preserving prior-turn bytes improv
137
 
138
  Works with any language, any tool, any framework. **[Proxy docs](docs/proxy.md)**
139
 
140
- Prefer Docker as the runtime provider? See **[Docker-native install](docs/docker-install.md)**.
141
 
142
  ### Coding agents — one command
143
 
@@ -197,6 +202,7 @@ Gives your AI tool three MCP tools: `headroom_compress`, `headroom_retrieve`, `h
197
  | **Claude Code** | Wrap | `headroom wrap claude` |
198
  | **GitHub Copilot CLI** | Wrap | `headroom wrap copilot -- --model claude-sonnet-4-20250514` |
199
  | **Codex / Aider** | Wrap | `headroom wrap codex` or `headroom wrap aider` |
 
200
 
201
  **[Full Integration Guide](docs/integration-guide.md)** | **[TypeScript SDK](docs/typescript-sdk.md)**
202
 
 
92
  irm https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.ps1 | iex
93
  ```
94
 
95
+ **Persistent local runtime:**
96
+ ```bash
97
+ headroom install apply --preset persistent-service --providers auto
98
+ ```
99
+
100
  ### Any agent — one function
101
 
102
  **Python:**
 
142
 
143
  Works with any language, any tool, any framework. **[Proxy docs](docs/proxy.md)**
144
 
145
+ Prefer Docker as the runtime provider? See **[Docker-native install](docs/docker-install.md)**. Want Headroom to stay up in the background? See **[Persistent installs](docs/persistent-installs.md)**.
146
 
147
  ### Coding agents — one command
148
 
 
202
  | **Claude Code** | Wrap | `headroom wrap claude` |
203
  | **GitHub Copilot CLI** | Wrap | `headroom wrap copilot -- --model claude-sonnet-4-20250514` |
204
  | **Codex / Aider** | Wrap | `headroom wrap codex` or `headroom wrap aider` |
205
+ | **Always-on local proxy** | Persistent install | `headroom install apply --preset persistent-service --providers auto` |
206
 
207
  **[Full Integration Guide](docs/integration-guide.md)** | **[TypeScript SDK](docs/typescript-sdk.md)**
208
 
docker/docker-compose.native.yml CHANGED
@@ -19,6 +19,7 @@ services:
19
  image: ${HEADROOM_IMAGE:-ghcr.io/chopratejas/headroom:latest}
20
  entrypoint: ["headroom", "proxy"]
21
  working_dir: /workspace
 
22
  environment:
23
  HOME: /tmp/headroom-home
24
  HEADROOM_HOST: 0.0.0.0
 
19
  image: ${HEADROOM_IMAGE:-ghcr.io/chopratejas/headroom:latest}
20
  entrypoint: ["headroom", "proxy"]
21
  working_dir: /workspace
22
+ restart: unless-stopped
23
  environment:
24
  HOME: /tmp/headroom-home
25
  HEADROOM_HOST: 0.0.0.0
docs/cli.md CHANGED
@@ -22,6 +22,7 @@ This page is the authoritative reference for the **Python Headroom CLI** exposed
22
 
23
  | Command | Purpose | Docker-native parity |
24
  |---|---|---|
 
25
  | `headroom proxy` | Run the Headroom proxy server | **native in container** |
26
  | `headroom learn` | Learn from past tool-call failures | **native in container** |
27
  | `headroom perf` | Summarize recent proxy performance | **native in container** |
@@ -29,6 +30,7 @@ This page is the authoritative reference for the **Python Headroom CLI** exposed
29
  | `headroom memory ...` | Inspect and manage stored memories | **native in container** |
30
  | `headroom mcp ...` | Install, inspect, remove, or serve MCP integration | **native in container** |
31
  | `headroom wrap claude` | Start proxy and launch Claude Code | **host-bridged** |
 
32
  | `headroom wrap codex` | Start proxy and launch Codex CLI | **host-bridged** |
33
  | `headroom wrap aider` | Start proxy and launch Aider | **host-bridged** |
34
  | `headroom wrap cursor` | Start proxy and print Cursor config guidance | **host-bridged** |
@@ -59,6 +61,7 @@ Options:
59
 
60
  Commands:
61
  evals Memory evaluation commands.
 
62
  learn Learn from past tool call failures to prevent future ones.
63
  mcp MCP server for Claude Code integration.
64
  memory Manage memories stored in Headroom.
@@ -168,6 +171,28 @@ Commands:
168
 
169
  </details>
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  <details>
172
  <summary><code>headroom wrap --help</code></summary>
173
 
@@ -179,6 +204,7 @@ Usage: headroom wrap [OPTIONS] COMMAND [ARGS]...
179
  Commands:
180
  aider Launch aider through Headroom proxy.
181
  claude Launch Claude Code through Headroom proxy.
 
182
  codex Launch OpenAI Codex CLI through Headroom proxy.
183
  cursor Start Headroom proxy for use with Cursor.
184
  openclaw Install and configure Headroom OpenClaw plugin in one command.
@@ -536,6 +562,118 @@ headroom mcp serve --proxy-url http://127.0.0.1:9000 --debug
536
 
537
  See also: [MCP Tools](mcp.md)
538
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
  ## `headroom wrap`
540
 
541
  Wrap external coding tools so their traffic flows through Headroom.
@@ -589,6 +727,29 @@ headroom wrap codex --backend anyllm --anyllm-provider groq
589
 
590
  Requires the `codex` binary on the host.
591
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592
  ### `headroom wrap aider`
593
 
594
  ```bash
@@ -691,14 +852,16 @@ Legend:
691
  | `headroom mcp uninstall` | native | native in container | full |
692
  | `headroom mcp status` | native | native in container | full |
693
  | `headroom mcp serve` | native | native in container | full |
 
694
  | `headroom wrap claude` | native | host-bridged | partial |
 
695
  | `headroom wrap codex` | native | host-bridged | partial |
696
  | `headroom wrap aider` | native | host-bridged | partial |
697
  | `headroom wrap cursor` | native | host-bridged | partial |
698
  | `headroom wrap openclaw` | native | host-bridged | partial |
699
  | `headroom unwrap openclaw` | native | host-bridged | partial |
700
 
701
- For the Docker-native execution model itself, see [Docker-Native Install](docker-install.md).
702
 
703
  ## Hidden and compatibility-only command paths
704
 
 
22
 
23
  | Command | Purpose | Docker-native parity |
24
  |---|---|---|
25
+ | `headroom install ...` | Install and manage persistent deployments | **python-native / compose-managed Docker** |
26
  | `headroom proxy` | Run the Headroom proxy server | **native in container** |
27
  | `headroom learn` | Learn from past tool-call failures | **native in container** |
28
  | `headroom perf` | Summarize recent proxy performance | **native in container** |
 
30
  | `headroom memory ...` | Inspect and manage stored memories | **native in container** |
31
  | `headroom mcp ...` | Install, inspect, remove, or serve MCP integration | **native in container** |
32
  | `headroom wrap claude` | Start proxy and launch Claude Code | **host-bridged** |
33
+ | `headroom wrap copilot` | Start proxy and launch GitHub Copilot CLI | **host-bridged** |
34
  | `headroom wrap codex` | Start proxy and launch Codex CLI | **host-bridged** |
35
  | `headroom wrap aider` | Start proxy and launch Aider | **host-bridged** |
36
  | `headroom wrap cursor` | Start proxy and print Cursor config guidance | **host-bridged** |
 
61
 
62
  Commands:
63
  evals Memory evaluation commands.
64
+ install Install and manage persistent Headroom deployments.
65
  learn Learn from past tool call failures to prevent future ones.
66
  mcp MCP server for Claude Code integration.
67
  memory Manage memories stored in Headroom.
 
171
 
172
  </details>
173
 
174
+ <details>
175
+ <summary><code>headroom install --help</code></summary>
176
+
177
+ ```text
178
+ Usage: headroom install [OPTIONS] COMMAND [ARGS]...
179
+
180
+ Install and manage persistent Headroom deployments.
181
+
182
+ Options:
183
+ -?, --help Show this message and exit.
184
+
185
+ Commands:
186
+ apply Install a persistent Headroom deployment.
187
+ remove Remove a persistent deployment and undo managed config.
188
+ restart Restart a persistent deployment.
189
+ start Start a persistent deployment.
190
+ status Show persistent deployment status.
191
+ stop Stop a persistent deployment.
192
+ ```
193
+
194
+ </details>
195
+
196
  <details>
197
  <summary><code>headroom wrap --help</code></summary>
198
 
 
204
  Commands:
205
  aider Launch aider through Headroom proxy.
206
  claude Launch Claude Code through Headroom proxy.
207
+ copilot Launch GitHub Copilot CLI through Headroom proxy.
208
  codex Launch OpenAI Codex CLI through Headroom proxy.
209
  cursor Start Headroom proxy for use with Cursor.
210
  openclaw Install and configure Headroom OpenClaw plugin in one command.
 
562
 
563
  See also: [MCP Tools](mcp.md)
564
 
565
+ ## `headroom install`
566
+
567
+ Install and manage persistent local Headroom deployments.
568
+
569
+ ### `headroom install apply --help`
570
+
571
+ ```text
572
+ Usage: headroom install apply [OPTIONS]
573
+
574
+ Install a persistent Headroom deployment.
575
+
576
+ Options:
577
+ --preset [persistent-service|persistent-task|persistent-docker]
578
+ Persistent runtime preset to install.
579
+ [default: persistent-service]
580
+ --runtime [python|docker] Runtime used to execute Headroom for
581
+ service/task modes. [default: python]
582
+ --scope [provider|user|system] Where to apply persistent configuration.
583
+ [default: user]
584
+ --providers [auto|all|manual] Target selection mode for direct tool
585
+ configuration. [default: auto]
586
+ --target [claude|copilot|codex|aider|cursor|openclaw]
587
+ Tool target to configure when --providers
588
+ manual is used.
589
+ --profile TEXT Deployment profile name. [default: default]
590
+ -p, --port INTEGER Persistent proxy port. [default: 8787]
591
+ --backend TEXT Proxy backend for the persistent runtime.
592
+ [default: anthropic]
593
+ --anyllm-provider TEXT Provider for any-llm backends when --backend
594
+ anyllm is used.
595
+ --region TEXT Cloud region for Bedrock / Vertex style
596
+ backends.
597
+ --mode TEXT Proxy optimization mode. [default: token]
598
+ --memory Enable persistent memory in the proxy runtime.
599
+ --no-telemetry Disable anonymous telemetry in the runtime.
600
+ --image TEXT Docker image to use when runtime=docker or
601
+ preset=persistent-docker. [default:
602
+ ghcr.io/chopratejas/headroom:latest]
603
+ -?, --help Show this message and exit.
604
+ ```
605
+
606
+ ### `headroom install apply`
607
+
608
+ ```bash
609
+ headroom install apply --preset persistent-service --providers auto
610
+ headroom install apply --preset persistent-task --providers manual --target claude --target codex
611
+ headroom install apply --preset persistent-docker --scope user
612
+ ```
613
+
614
+ | Option | Default | Meaning |
615
+ |---|---|---|
616
+ | `--preset` | `persistent-service` | Lifecycle preset: `persistent-service`, `persistent-task`, or `persistent-docker` |
617
+ | `--runtime` | `python` | Runtime used for service/task installs: `python` or `docker` |
618
+ | `--scope` | `user` | Config scope: `provider`, `user`, or `system` |
619
+ | `--providers` | `auto` | Target selection mode: `auto`, `all`, or `manual` |
620
+ | `--target` | repeatable | Tool target used with `--providers manual` |
621
+ | `--profile` | `default` | Deployment profile name |
622
+ | `--port`, `-p` | `8787` | Persistent proxy port |
623
+ | `--backend` | `anthropic` | Backend for the managed runtime |
624
+ | `--anyllm-provider` | unset | Provider name used with `--backend anyllm` |
625
+ | `--region` | unset | Cloud region override |
626
+ | `--mode` | `token` | Proxy optimization mode |
627
+ | `--memory` | off | Enable persistent memory in the managed runtime |
628
+ | `--no-telemetry` | off | Disable anonymous telemetry |
629
+ | `--image` | `ghcr.io/chopratejas/headroom:latest` | Docker image for Docker-backed installs |
630
+
631
+ `apply` stores a manifest under `~/.headroom/deploy/<profile>/manifest.json`, applies managed tool configuration, starts the chosen runtime, and waits for `readyz`.
632
+
633
+ ### `headroom install status`
634
+
635
+ ```bash
636
+ headroom install status
637
+ headroom install status --profile default
638
+ ```
639
+
640
+ Shows the stored profile, preset, runtime, supervisor kind, scope, port, runtime status, readiness, and backend from `/health`.
641
+
642
+ ### `headroom install start`
643
+
644
+ ```bash
645
+ headroom install start
646
+ headroom install start --profile default
647
+ ```
648
+
649
+ Starts a previously installed deployment profile without reapplying mutations.
650
+
651
+ ### `headroom install stop`
652
+
653
+ ```bash
654
+ headroom install stop
655
+ ```
656
+
657
+ Stops the managed runtime for an installed deployment profile.
658
+
659
+ ### `headroom install restart`
660
+
661
+ ```bash
662
+ headroom install restart
663
+ ```
664
+
665
+ Stops and starts the selected deployment profile.
666
+
667
+ ### `headroom install remove`
668
+
669
+ ```bash
670
+ headroom install remove
671
+ ```
672
+
673
+ Stops the runtime, removes installed supervisor artifacts, reverts managed configuration changes, and deletes the stored manifest.
674
+
675
+ See also: [Persistent Installs](persistent-installs.md)
676
+
677
  ## `headroom wrap`
678
 
679
  Wrap external coding tools so their traffic flows through Headroom.
 
727
 
728
  Requires the `codex` binary on the host.
729
 
730
+ ### `headroom wrap copilot`
731
+
732
+ ```bash
733
+ headroom wrap copilot -- --model claude-sonnet-4-20250514
734
+ headroom wrap copilot --backend anyllm --anyllm-provider groq -- --model gpt-4o
735
+ ```
736
+
737
+ | Option / arg | Default | Meaning |
738
+ |---|---|---|
739
+ | `--port`, `-p` | `8787` | Proxy port |
740
+ | `--no-rtk` | off | Skip `rtk` installation and GitHub Copilot instructions injection |
741
+ | `--no-proxy` | off | Reuse an existing proxy |
742
+ | `--learn` | off | Enable live traffic learning |
743
+ | `--backend` | unset | Proxy backend override |
744
+ | `--anyllm-provider` | unset | `anyllm` provider override |
745
+ | `--region` | unset | Cloud region override |
746
+ | `--provider-type` | `auto` | Force Copilot BYOK provider type (`anthropic` or `openai`) |
747
+ | `--wire-api` | unset | OpenAI wire API override for OpenAI-style backends |
748
+ | `--verbose`, `-v` | off | Verbose output |
749
+ | `copilot_args...` | passthrough | Additional Copilot CLI arguments |
750
+
751
+ Requires the `copilot` binary on the host. When a matching persistent deployment exists on the requested port, `wrap copilot` reuses or recovers it before falling back to an ephemeral proxy.
752
+
753
  ### `headroom wrap aider`
754
 
755
  ```bash
 
852
  | `headroom mcp uninstall` | native | native in container | full |
853
  | `headroom mcp status` | native | native in container | full |
854
  | `headroom mcp serve` | native | native in container | full |
855
+ | `headroom install ...` | native | compose-managed persistent Docker path | partial |
856
  | `headroom wrap claude` | native | host-bridged | partial |
857
+ | `headroom wrap copilot` | native | host-bridged | partial |
858
  | `headroom wrap codex` | native | host-bridged | partial |
859
  | `headroom wrap aider` | native | host-bridged | partial |
860
  | `headroom wrap cursor` | native | host-bridged | partial |
861
  | `headroom wrap openclaw` | native | host-bridged | partial |
862
  | `headroom unwrap openclaw` | native | host-bridged | partial |
863
 
864
+ For the Docker-native execution model itself, see [Docker-Native Install](docker-install.md). For persistent service/task/docker lifecycle management, see [Persistent Installs](persistent-installs.md).
865
 
866
  ## Hidden and compatibility-only command paths
867
 
docs/docker-install.md CHANGED
@@ -85,6 +85,24 @@ OpenClaw remains host-native in Docker-native mode:
85
 
86
  Use `docker/docker-compose.native.yml` when you want an explicit compose-managed proxy or CLI shell.
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  ### macOS / Linux
89
 
90
  ```bash
@@ -128,3 +146,4 @@ That keeps provider auth and runtime config working without maintaining a separa
128
  - Docker is the only required Headroom runtime dependency on the host.
129
  - Wrapped tools like Claude Code, Codex CLI, Aider, and Cursor still run on the host when you use `headroom wrap ...`.
130
  - The install scripts are idempotent: rerunning them refreshes the wrapper and image without duplicating shell profile blocks.
 
 
85
 
86
  Use `docker/docker-compose.native.yml` when you want an explicit compose-managed proxy or CLI shell.
87
 
88
+ ### Persistent Docker runtime
89
+
90
+ The `proxy` service now uses `restart: unless-stopped`, so compose can act as the always-on Docker runtime for Headroom:
91
+
92
+ ```bash
93
+ export HEADROOM_HOST_HOME="$HOME"
94
+ export HEADROOM_WORKSPACE="$PWD"
95
+ docker compose -f docker/docker-compose.native.yml up -d proxy
96
+ ```
97
+
98
+ ```powershell
99
+ $env:HEADROOM_HOST_HOME = $HOME
100
+ $env:HEADROOM_WORKSPACE = (Get-Location).Path
101
+ docker compose -f docker/docker-compose.native.yml up -d proxy
102
+ ```
103
+
104
+ This is the recommended persistent-Docker path when you installed Headroom through the Docker-native host wrapper.
105
+
106
  ### macOS / Linux
107
 
108
  ```bash
 
146
  - Docker is the only required Headroom runtime dependency on the host.
147
  - Wrapped tools like Claude Code, Codex CLI, Aider, and Cursor still run on the host when you use `headroom wrap ...`.
148
  - The install scripts are idempotent: rerunning them refreshes the wrapper and image without duplicating shell profile blocks.
149
+ - For persistent service and task installs, use the Python-native `headroom install ...` workflow described in [Persistent Installs](persistent-installs.md).
docs/getting-started.md CHANGED
@@ -40,6 +40,12 @@ irm https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.
40
 
41
  See [Docker-native install](docker-install.md) for wrapper behavior, compose usage, and host-integrated `wrap` flows.
42
 
 
 
 
 
 
 
43
  ## Quick Start: Proxy Mode (Recommended)
44
 
45
  The easiest way to use Headroom is as a proxy server:
 
40
 
41
  See [Docker-native install](docker-install.md) for wrapper behavior, compose usage, and host-integrated `wrap` flows.
42
 
43
+ If you want Headroom to stay up in the background and automatically serve supported tools, use [Persistent Installs](persistent-installs.md):
44
+
45
+ ```bash
46
+ headroom install apply --preset persistent-service --providers auto
47
+ ```
48
+
49
  ## Quick Start: Proxy Mode (Recommended)
50
 
51
  The easiest way to use Headroom is as a proxy server:
docs/index.md CHANGED
@@ -76,6 +76,8 @@ Headroom works as a **transparent proxy** (zero code changes), a **Python functi
76
 
77
  That's it. Your existing code works unchanged, with 40-90% fewer tokens.
78
 
 
 
79
  === "Python SDK"
80
 
81
  ```python
@@ -104,6 +106,8 @@ Headroom works as a **transparent proxy** (zero code changes), a **Python functi
104
 
105
  Starts the proxy, points your tool at it, compresses everything automatically.
106
 
 
 
107
  === "TypeScript SDK"
108
 
109
  ```typescript
 
76
 
77
  That's it. Your existing code works unchanged, with 40-90% fewer tokens.
78
 
79
+ Want an always-on local runtime instead? See [Persistent Installs &rarr;](persistent-installs.md).
80
+
81
  === "Python SDK"
82
 
83
  ```python
 
106
 
107
  Starts the proxy, points your tool at it, compresses everything automatically.
108
 
109
+ If you prefer an always-on proxy that `wrap` can reuse or recover, see [Persistent Installs &rarr;](persistent-installs.md).
110
+
111
  === "TypeScript SDK"
112
 
113
  ```typescript
docs/persistent-installs.md ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Persistent Installs
2
+
3
+ Headroom can now be installed as a durable local runtime instead of only being started ad hoc with `headroom proxy` or `headroom wrap ...`.
4
+
5
+ Use `headroom install` when you want supported tools to keep talking to an always-on proxy at `http://127.0.0.1:8787` and have `wrap` reuse or recover that deployment instead of starting a second ephemeral proxy.
6
+
7
+ ## Runtime matrix
8
+
9
+ | Mode | What stays running | Primary entrypoint |
10
+ |---|---|---|
11
+ | Persistent Service | Native background service | `headroom install apply --preset persistent-service` |
12
+ | Persistent Task | Scheduled watchdog + on-demand runner | `headroom install apply --preset persistent-task` |
13
+ | Persistent Docker | Restartable Docker container | `headroom install apply --preset persistent-docker` |
14
+ | On-Demand CLI (Python) | Nothing after command exits | `headroom proxy` |
15
+ | On-Demand CLI (Docker) | Nothing after container exits | Docker-native wrapper / compose CLI |
16
+ | Wrapped (Python) | Proxy lasts for wrapped session | `headroom wrap ...` |
17
+ | Wrapped (Docker) | Containerized proxy + host tool session | Docker-native wrapper |
18
+
19
+ ## Quick examples
20
+
21
+ ### Persistent service on the local machine
22
+
23
+ ```bash
24
+ headroom install apply --preset persistent-service --providers auto
25
+ headroom install status
26
+ ```
27
+
28
+ This installs a background service on the current machine, applies persistent tool wiring, and keeps the proxy healthy on port `8787`.
29
+
30
+ ### Persistent watchdog task
31
+
32
+ ```bash
33
+ headroom install apply --preset persistent-task --providers manual --target claude --target codex
34
+ ```
35
+
36
+ This installs a scheduled recovery path instead of a traditional always-running service.
37
+
38
+ ### Persistent Docker
39
+
40
+ ```bash
41
+ headroom install apply --preset persistent-docker --scope user --providers auto
42
+ ```
43
+
44
+ This uses Docker's restart policy instead of an OS supervisor.
45
+
46
+ If you are using the Docker-native host wrapper instead of a Python install, use the compose-managed path documented in [Docker-Native Install](docker-install.md).
47
+
48
+ ## Command surface
49
+
50
+ ```text
51
+ headroom install apply
52
+ headroom install status
53
+ headroom install start
54
+ headroom install stop
55
+ headroom install restart
56
+ headroom install remove
57
+ ```
58
+
59
+ `apply` creates or updates a named deployment profile, stores its manifest under `~/.headroom/deploy/<profile>/manifest.json`, applies reversible configuration changes, and starts the selected runtime.
60
+
61
+ ## Presets and runtime kinds
62
+
63
+ ### Presets
64
+
65
+ - `persistent-service` -> native service supervisor
66
+ - `persistent-task` -> scheduled watchdog / recovery supervisor
67
+ - `persistent-docker` -> Docker restart policy with no extra OS supervisor
68
+
69
+ ### Runtime kinds
70
+
71
+ - `--runtime python` runs `headroom proxy` directly
72
+ - `--runtime docker` runs Headroom inside Docker while keeping the deployment managed locally
73
+
74
+ For `persistent-docker`, the runtime is always Docker.
75
+
76
+ ## Configuration scopes
77
+
78
+ | Scope | What changes |
79
+ |---|---|
80
+ | `provider` | Tool-specific config surfaces where Headroom can make a precise reversible edit |
81
+ | `user` | User-level shell or environment surfaces |
82
+ | `system` | Machine-wide shell or environment surfaces |
83
+
84
+ ### Provider scope today
85
+
86
+ Provider scope is intentionally conservative. The current direct adapters are:
87
+
88
+ - Claude Code -> `~/.claude/settings.json` `env`
89
+ - Codex -> managed block in `~/.codex/config.toml`
90
+ - OpenClaw -> existing `wrap openclaw` / `unwrap openclaw` flow
91
+
92
+ For Copilot, Aider, Cursor, and broader env-driven setups, prefer `--scope user` or `--scope system`.
93
+
94
+ ## Provider selection
95
+
96
+ | Option | Meaning |
97
+ |---|---|
98
+ | `--providers auto` | Detect supported tools on the host and configure the best available defaults |
99
+ | `--providers all` | Configure all known targets |
100
+ | `--providers manual --target ...` | Configure only the named tools |
101
+
102
+ Examples:
103
+
104
+ ```bash
105
+ headroom install apply --providers auto
106
+ headroom install apply --providers all --scope user
107
+ headroom install apply --providers manual --target claude --target copilot
108
+ ```
109
+
110
+ ## Health and wrap behavior
111
+
112
+ Persistent deployments publish the same `readyz` and `health` endpoints as ad hoc proxy runs.
113
+
114
+ `/health` now also exposes deployment metadata when the proxy was launched through the install subsystem:
115
+
116
+ ```json
117
+ {
118
+ "deployment": {
119
+ "profile": "default",
120
+ "preset": "persistent-service",
121
+ "runtime": "python",
122
+ "supervisor": "service",
123
+ "scope": "user"
124
+ }
125
+ }
126
+ ```
127
+
128
+ `headroom wrap ...` checks for a matching persistent deployment on the requested port before it starts a new ephemeral proxy. If an installed deployment exists but is stopped or unhealthy, `wrap` attempts to recover it first.
129
+
130
+ ## Docker-native relationship
131
+
132
+ The Docker-native host wrapper and the Python install CLI solve different layers of the runtime story:
133
+
134
+ - [Docker-Native Install](docker-install.md) -> containerized on-demand CLI and wrapped host-tool flows
135
+ - `headroom install ...` -> persistent service, task, and native Docker lifecycle management
136
+
137
+ For a no-Python persistent Docker workflow, use the compose-managed proxy path from `docker/docker-compose.native.yml`:
138
+
139
+ ```bash
140
+ export HEADROOM_HOST_HOME="$HOME"
141
+ export HEADROOM_WORKSPACE="$PWD"
142
+ docker compose -f docker/docker-compose.native.yml up -d proxy
143
+ ```
144
+
145
+ That keeps `localhost:8787` stable and restarts the proxy automatically.
146
+
147
+ ## Related guides
148
+
149
+ - [CLI Reference](cli.md)
150
+ - [Docker-Native Install](docker-install.md)
151
+ - [Proxy Server](proxy.md)
152
+ - [macOS LaunchAgent](macos-deployment.md)
docs/plans/2026-04-11-persistent-installs-design.md ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Persistent Deployments / Installs Design
2
+
3
+ ## Problem
4
+
5
+ Headroom already supports session-oriented usage through `headroom proxy`, `headroom wrap ...`, and the Docker-native wrapper scripts, but there is no first-class way to install Headroom as a durable background runtime. That leaves users to hand-roll launch agents, services, scheduled tasks, or Docker restart policies, and it keeps direct tool usage (`claude`, `codex`, `copilot`, `openclaw`, etc.) tied to explicit `wrap` commands.
6
+
7
+ The new feature should make Headroom deployable as a persistent local runtime while keeping the existing on-demand and wrapped flows intact.
8
+
9
+ ## Goals
10
+
11
+ - Support these runtime/install modes as one coherent system:
12
+ - Persistent Service
13
+ - Persistent Task
14
+ - Persistent Docker
15
+ - On-Demand CLI (Python)
16
+ - On-Demand CLI (Docker)
17
+ - Wrapped (Python)
18
+ - Wrapped (Docker)
19
+ - Support install target selection modes:
20
+ - Auto-Detect
21
+ - All
22
+ - Manual Select
23
+ - Support configuration scopes:
24
+ - Provider
25
+ - User
26
+ - System
27
+ - Keep `wrap` idempotent and persistent-aware.
28
+ - Preserve local defaults such as `localhost:8787`.
29
+
30
+ ## Architecture
31
+
32
+ Introduce a new shared deployment subsystem under `headroom.install`.
33
+
34
+ Core model:
35
+
36
+ - `execution_mode`: `persistent | on_demand | wrapped`
37
+ - `runtime_kind`: `python | docker`
38
+ - `supervisor_kind`: `service | task | none`
39
+
40
+ These three axes normalize all seven user-facing runtime modes without duplicating logic across CLI commands, install scripts, and platform-specific deployment adapters.
41
+
42
+ The subsystem centers on a persisted deployment manifest in `~/.headroom/deploy/` that records:
43
+
44
+ - resolved proxy configuration
45
+ - runtime type
46
+ - supervisor type
47
+ - configured tool targets
48
+ - applied config mutations
49
+ - generated artifact paths
50
+ - health URL and port
51
+
52
+ ## Command model
53
+
54
+ Add a new public `headroom install` group:
55
+
56
+ - `headroom install apply`
57
+ - `headroom install status`
58
+ - `headroom install start`
59
+ - `headroom install stop`
60
+ - `headroom install restart`
61
+ - `headroom install remove`
62
+
63
+ Add hidden helper commands for artifact runners and health recovery:
64
+
65
+ - `headroom install agent run --profile <name>`
66
+ - `headroom install agent ensure --profile <name>`
67
+
68
+ Platform supervisors should register the hidden agent entrypoint rather than raw `headroom proxy ...` so restart, health polling, and manifest handling live in one place.
69
+
70
+ ## Runtime adapters
71
+
72
+ - `PythonRuntimeAdapter`: launches `headroom proxy` directly.
73
+ - `DockerRuntimeAdapter`: launches a detached or foreground Docker container with the existing host mounts and loopback-only port publishing.
74
+
75
+ Persistent Docker uses the same deployment manifest and status semantics as service/task installs, but the child runtime is Docker-managed rather than OS-supervised.
76
+
77
+ ## Supervisor adapters
78
+
79
+ - Linux
80
+ - Service: systemd unit
81
+ - Task: cron watchdog + reboot/start entry
82
+ - macOS
83
+ - Service: LaunchDaemon / LaunchAgent variant
84
+ - Task: launchd user agent or cron-style watchdog where appropriate
85
+ - Windows
86
+ - Service: Windows Service wrapper
87
+ - Task: Scheduled Task startup + periodic health-check task
88
+
89
+ Each adapter renders artifacts into `~/.headroom/deploy/` and stores enough metadata for clean removal.
90
+
91
+ ## Tool target configuration
92
+
93
+ Provider-level configuration should be target-specific and reversible.
94
+
95
+ Initial target adapters:
96
+
97
+ - Claude Code
98
+ - write `env` keys into Claude settings JSON where appropriate
99
+ - Codex
100
+ - manage a marked block or targeted settings in `~/.codex/config.toml`
101
+ - Copilot CLI
102
+ - configure BYOK environment surfaces using persistent env strategy
103
+ - OpenClaw
104
+ - reuse existing OpenClaw config/plugin merge logic where possible
105
+ - Aider / Cursor
106
+ - use env-based integration first, with tool-specific config only where a stable supported surface exists
107
+
108
+ All non-marker edits must store previous values in the deployment manifest so uninstall removes only Headroom-managed changes.
109
+
110
+ ## Wrap behavior
111
+
112
+ `headroom wrap ...` should consult the active deployment manifest before starting a new proxy. If a compatible persistent deployment is present and healthy, `wrap` should reuse it and only perform any remaining tool-specific preparation. If the deployment exists but is unhealthy, `wrap` should attempt to recover it through the install subsystem before falling back to an ephemeral proxy.
113
+
114
+ ## Docs strategy
115
+
116
+ The public docs should be reframed around:
117
+
118
+ - runtime mode
119
+ - lifecycle mode
120
+ - configuration scope
121
+ - direct-use vs `wrap`
122
+
123
+ Create a new top-level guide at `docs/persistent-installs.md` and update the existing Docker install, proxy, CLI, getting-started, quickstart, configuration, troubleshooting, and integration docs to reflect the broader runtime story.
124
+
125
+ ## Risks
126
+
127
+ - Windows service installation is the highest-risk platform path and needs strong test isolation.
128
+ - Provider-specific config mutation must remain conservative and reversible.
129
+ - `/health` should gain deployment metadata without breaking the existing `config` payload shape expected by current tests and docs.
docs/quickstart.md CHANGED
@@ -33,6 +33,14 @@ curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/i
33
 
34
  See [Docker-native install](docker-install.md) if you want Docker to provide the Headroom runtime while your agent CLIs stay on the host.
35
 
 
 
 
 
 
 
 
 
36
  ---
37
 
38
  ## Option 1: Proxy Server (Zero Code Changes)
 
33
 
34
  See [Docker-native install](docker-install.md) if you want Docker to provide the Headroom runtime while your agent CLIs stay on the host.
35
 
36
+ **Persistent background runtime:**
37
+
38
+ ```bash
39
+ headroom install apply --preset persistent-service --providers auto
40
+ ```
41
+
42
+ See [Persistent Installs](persistent-installs.md) if you want Headroom to stay up in the background and be reused by `wrap`.
43
+
44
  ---
45
 
46
  ## Option 1: Proxy Server (Zero Code Changes)
headroom/cli/install.py ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persistent install / deployment CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from headroom.install.health import probe_json, probe_ready
8
+ from headroom.install.models import (
9
+ ConfigScope,
10
+ DeploymentManifest,
11
+ InstallPreset,
12
+ ProviderSelectionMode,
13
+ RuntimeKind,
14
+ SupervisorKind,
15
+ )
16
+ from headroom.install.planner import build_manifest
17
+ from headroom.install.providers import apply_mutations, revert_mutations
18
+ from headroom.install.runtime import (
19
+ run_foreground,
20
+ runtime_status,
21
+ start_detached_agent,
22
+ start_persistent_docker,
23
+ stop_runtime,
24
+ wait_ready,
25
+ )
26
+ from headroom.install.state import delete_manifest, load_manifest, save_manifest
27
+ from headroom.install.supervisors import (
28
+ install_supervisor,
29
+ remove_supervisor,
30
+ start_supervisor,
31
+ stop_supervisor,
32
+ )
33
+
34
+ from .main import main
35
+
36
+
37
+ @main.group()
38
+ def install() -> None:
39
+ """Install and manage persistent Headroom deployments."""
40
+
41
+
42
+ def _require_manifest(profile: str) -> DeploymentManifest:
43
+ manifest = load_manifest(profile)
44
+ if manifest is None:
45
+ raise click.ClickException(f"No deployment profile named '{profile}' is installed.")
46
+ return manifest
47
+
48
+
49
+ def _start_deployment(manifest: DeploymentManifest) -> None:
50
+ if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
51
+ start_persistent_docker(manifest)
52
+ elif manifest.supervisor_kind == SupervisorKind.SERVICE.value:
53
+ start_supervisor(manifest)
54
+ else:
55
+ start_detached_agent(manifest.profile)
56
+
57
+ if not wait_ready(manifest, timeout_seconds=45):
58
+ raise click.ClickException(
59
+ f"Deployment '{manifest.profile}' did not become ready after start."
60
+ )
61
+
62
+
63
+ def _stop_deployment(manifest: DeploymentManifest) -> None:
64
+ if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
65
+ stop_supervisor(manifest)
66
+ stop_runtime(manifest)
67
+
68
+
69
+ @install.command("apply")
70
+ @click.option(
71
+ "--preset",
72
+ type=click.Choice([preset.value for preset in InstallPreset]),
73
+ default=InstallPreset.PERSISTENT_SERVICE.value,
74
+ show_default=True,
75
+ help="Persistent runtime preset to install.",
76
+ )
77
+ @click.option(
78
+ "--runtime",
79
+ type=click.Choice([runtime.value for runtime in RuntimeKind]),
80
+ default=RuntimeKind.PYTHON.value,
81
+ show_default=True,
82
+ help="Runtime used to execute Headroom for service/task modes.",
83
+ )
84
+ @click.option(
85
+ "--scope",
86
+ type=click.Choice([scope.value for scope in ConfigScope]),
87
+ default=ConfigScope.USER.value,
88
+ show_default=True,
89
+ help="Where to apply persistent configuration.",
90
+ )
91
+ @click.option(
92
+ "--providers",
93
+ "provider_mode",
94
+ type=click.Choice([mode.value for mode in ProviderSelectionMode]),
95
+ default=ProviderSelectionMode.AUTO.value,
96
+ show_default=True,
97
+ help="Target selection mode for direct tool configuration.",
98
+ )
99
+ @click.option(
100
+ "--target",
101
+ "targets",
102
+ multiple=True,
103
+ type=click.Choice(["claude", "copilot", "codex", "aider", "cursor", "openclaw"]),
104
+ help="Tool target to configure when --providers manual is used.",
105
+ )
106
+ @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
107
+ @click.option(
108
+ "--port", "-p", default=8787, type=int, show_default=True, help="Persistent proxy port."
109
+ )
110
+ @click.option(
111
+ "--backend",
112
+ default="anthropic",
113
+ show_default=True,
114
+ help="Proxy backend for the persistent runtime.",
115
+ )
116
+ @click.option(
117
+ "--anyllm-provider",
118
+ default=None,
119
+ help="Provider for any-llm backends when --backend anyllm is used.",
120
+ )
121
+ @click.option("--region", default=None, help="Cloud region for Bedrock / Vertex style backends.")
122
+ @click.option(
123
+ "--mode", "proxy_mode", default="token", show_default=True, help="Proxy optimization mode."
124
+ )
125
+ @click.option("--memory", is_flag=True, help="Enable persistent memory in the proxy runtime.")
126
+ @click.option("--no-telemetry", is_flag=True, help="Disable anonymous telemetry in the runtime.")
127
+ @click.option(
128
+ "--image",
129
+ default="ghcr.io/chopratejas/headroom:latest",
130
+ show_default=True,
131
+ help="Docker image to use when runtime=docker or preset=persistent-docker.",
132
+ )
133
+ def install_apply(
134
+ preset: str,
135
+ runtime: str,
136
+ scope: str,
137
+ provider_mode: str,
138
+ targets: tuple[str, ...],
139
+ profile: str,
140
+ port: int,
141
+ backend: str,
142
+ anyllm_provider: str | None,
143
+ region: str | None,
144
+ proxy_mode: str,
145
+ memory: bool,
146
+ no_telemetry: bool,
147
+ image: str,
148
+ ) -> None:
149
+ """Install a persistent Headroom deployment."""
150
+
151
+ if preset == InstallPreset.PERSISTENT_DOCKER.value:
152
+ runtime = RuntimeKind.DOCKER.value
153
+
154
+ manifest = build_manifest(
155
+ profile=profile,
156
+ preset=preset,
157
+ runtime_kind=runtime,
158
+ scope=scope,
159
+ provider_mode=provider_mode,
160
+ targets=list(targets),
161
+ port=port,
162
+ backend=backend,
163
+ anyllm_provider=anyllm_provider,
164
+ region=region,
165
+ proxy_mode=proxy_mode,
166
+ memory_enabled=memory,
167
+ telemetry_enabled=not no_telemetry,
168
+ image=image,
169
+ )
170
+
171
+ existing = load_manifest(profile)
172
+ if existing is not None:
173
+ click.echo(f"Updating existing deployment profile '{profile}'...")
174
+ revert_mutations(existing)
175
+ try:
176
+ remove_supervisor(existing)
177
+ except Exception:
178
+ pass
179
+ stop_runtime(existing)
180
+
181
+ manifest.mutations = apply_mutations(manifest)
182
+ manifest.artifacts = install_supervisor(manifest)
183
+ save_manifest(manifest)
184
+
185
+ if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
186
+ start_persistent_docker(manifest)
187
+ elif manifest.supervisor_kind == SupervisorKind.SERVICE.value:
188
+ start_supervisor(manifest)
189
+ else:
190
+ start_detached_agent(profile)
191
+
192
+ if not wait_ready(manifest, timeout_seconds=45):
193
+ raise click.ClickException(
194
+ f"Persistent deployment '{profile}' did not become ready at {manifest.health_url}."
195
+ )
196
+
197
+ click.echo(
198
+ f"Installed persistent deployment '{profile}' "
199
+ f"({manifest.preset}, runtime={manifest.runtime_kind}, scope={manifest.scope})."
200
+ )
201
+ click.echo(f"Health: {manifest.health_url}")
202
+ if manifest.targets:
203
+ click.echo(f"Targets: {', '.join(manifest.targets)}")
204
+
205
+
206
+ @install.command("status")
207
+ @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
208
+ def install_status(profile: str) -> None:
209
+ """Show persistent deployment status."""
210
+
211
+ manifest = _require_manifest(profile)
212
+ payload = probe_json(manifest.health_url.replace("/readyz", "/health"))
213
+ click.echo(f"Profile: {manifest.profile}")
214
+ click.echo(f"Preset: {manifest.preset}")
215
+ click.echo(f"Runtime: {manifest.runtime_kind}")
216
+ click.echo(f"Supervisor: {manifest.supervisor_kind}")
217
+ click.echo(f"Scope: {manifest.scope}")
218
+ click.echo(f"Port: {manifest.port}")
219
+ click.echo(f"Status: {runtime_status(manifest)}")
220
+ click.echo(f"Healthy: {'yes' if probe_ready(manifest.health_url) else 'no'}")
221
+ if payload and isinstance(payload, dict):
222
+ click.echo(f"Health URL: {manifest.health_url.replace('/readyz', '/health')}")
223
+ click.echo(f"Backend: {payload.get('config', {}).get('backend', manifest.backend)}")
224
+
225
+
226
+ @install.command("start")
227
+ @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
228
+ def install_start(profile: str) -> None:
229
+ """Start a persistent deployment."""
230
+
231
+ manifest = _require_manifest(profile)
232
+ _start_deployment(manifest)
233
+ click.echo(f"Started deployment '{profile}'.")
234
+
235
+
236
+ @install.command("stop")
237
+ @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
238
+ def install_stop(profile: str) -> None:
239
+ """Stop a persistent deployment."""
240
+
241
+ manifest = _require_manifest(profile)
242
+ _stop_deployment(manifest)
243
+ click.echo(f"Stopped deployment '{profile}'.")
244
+
245
+
246
+ @install.command("restart")
247
+ @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
248
+ def install_restart(profile: str) -> None:
249
+ """Restart a persistent deployment."""
250
+
251
+ manifest = _require_manifest(profile)
252
+ _stop_deployment(manifest)
253
+ _start_deployment(manifest)
254
+ click.echo(f"Restarted deployment '{profile}'.")
255
+
256
+
257
+ @install.command("remove")
258
+ @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
259
+ def install_remove(profile: str) -> None:
260
+ """Remove a persistent deployment and undo managed config."""
261
+
262
+ manifest = _require_manifest(profile)
263
+ try:
264
+ if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
265
+ stop_supervisor(manifest)
266
+ except Exception:
267
+ pass
268
+ try:
269
+ stop_runtime(manifest)
270
+ except Exception:
271
+ pass
272
+ try:
273
+ remove_supervisor(manifest)
274
+ except Exception:
275
+ pass
276
+ revert_mutations(manifest)
277
+ delete_manifest(profile)
278
+ click.echo(f"Removed deployment '{profile}'.")
279
+
280
+
281
+ @install.group("agent", hidden=True)
282
+ def install_agent() -> None:
283
+ """Hidden runtime helpers used by persistent supervisors."""
284
+
285
+
286
+ @install_agent.command("run")
287
+ @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
288
+ def install_agent_run(profile: str) -> None:
289
+ """Run the persistent runtime in the foreground."""
290
+
291
+ manifest = _require_manifest(profile)
292
+ raise SystemExit(run_foreground(manifest))
293
+
294
+
295
+ @install_agent.command("ensure")
296
+ @click.option("--profile", default="default", show_default=True, help="Deployment profile name.")
297
+ def install_agent_ensure(profile: str) -> None:
298
+ """Ensure a persistent deployment is healthy, starting it when needed."""
299
+
300
+ manifest = _require_manifest(profile)
301
+ if probe_ready(manifest.health_url):
302
+ click.echo(f"Deployment '{profile}' is already healthy.")
303
+ return
304
+ _start_deployment(manifest)
305
+ click.echo(f"Deployment '{profile}' is healthy.")
headroom/cli/main.py CHANGED
@@ -37,6 +37,7 @@ def _register_commands() -> None:
37
  """Register all subcommand groups."""
38
  from . import (
39
  evals, # noqa: F401
 
40
  learn, # noqa: F401
41
  mcp, # noqa: F401
42
  perf, # noqa: F401
 
37
  """Register all subcommand groups."""
38
  from . import (
39
  evals, # noqa: F401
40
+ install, # noqa: F401
41
  learn, # noqa: F401
42
  mcp, # noqa: F401
43
  perf, # noqa: F401
headroom/cli/wrap.py CHANGED
@@ -436,6 +436,52 @@ def _detect_running_proxy_backend(port: int) -> str | None:
436
  return backend if isinstance(backend, str) else None
437
 
438
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  def _copilot_model_configured(copilot_args: tuple[str, ...], env: dict[str, str]) -> bool:
440
  """Return True when Copilot BYOK model selection is configured."""
441
  if env.get("COPILOT_MODEL") or env.get("COPILOT_PROVIDER_MODEL_ID"):
@@ -462,6 +508,16 @@ def _ensure_proxy(
462
  ) -> subprocess.Popen | None:
463
  """Start or verify proxy. Returns process handle if we started it."""
464
  if not no_proxy:
 
 
 
 
 
 
 
 
 
 
465
  if _check_proxy(port):
466
  click.echo(f" Proxy already running on port {port}")
467
  return None
 
436
  return backend if isinstance(backend, str) else None
437
 
438
 
439
+ def _find_persistent_manifest(port: int) -> Any:
440
+ """Return a matching persistent deployment manifest for the requested port."""
441
+ from headroom.install.state import list_manifests
442
+
443
+ manifests = [manifest for manifest in list_manifests() if manifest.port == port]
444
+ manifests.sort(key=lambda manifest: (manifest.profile != "default", manifest.profile))
445
+ return manifests[0] if manifests else None
446
+
447
+
448
+ def _recover_persistent_proxy(port: int) -> bool:
449
+ """Start or recover a matching persistent deployment for the requested port."""
450
+ from headroom.install.health import probe_ready
451
+ from headroom.install.models import InstallPreset, SupervisorKind
452
+ from headroom.install.runtime import start_detached_agent, start_persistent_docker, wait_ready
453
+ from headroom.install.supervisors import start_supervisor
454
+
455
+ manifest = _find_persistent_manifest(port)
456
+ if manifest is None:
457
+ return False
458
+
459
+ if probe_ready(manifest.health_url):
460
+ click.echo(f" Reusing persistent deployment '{manifest.profile}' on port {port}")
461
+ return True
462
+
463
+ click.echo(f" Recovering persistent deployment '{manifest.profile}' on port {port}...")
464
+ try:
465
+ if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
466
+ start_persistent_docker(manifest)
467
+ elif manifest.supervisor_kind == SupervisorKind.SERVICE.value:
468
+ start_supervisor(manifest)
469
+ else:
470
+ start_detached_agent(manifest.profile)
471
+ except Exception as exc:
472
+ click.echo(
473
+ f" Warning: could not recover persistent deployment '{manifest.profile}': {exc}"
474
+ )
475
+ return False
476
+
477
+ if wait_ready(manifest, timeout_seconds=45):
478
+ click.echo(f" Recovered persistent deployment '{manifest.profile}' on port {port}")
479
+ return True
480
+
481
+ click.echo(f" Warning: persistent deployment '{manifest.profile}' did not become ready")
482
+ return False
483
+
484
+
485
  def _copilot_model_configured(copilot_args: tuple[str, ...], env: dict[str, str]) -> bool:
486
  """Return True when Copilot BYOK model selection is configured."""
487
  if env.get("COPILOT_MODEL") or env.get("COPILOT_PROVIDER_MODEL_ID"):
 
508
  ) -> subprocess.Popen | None:
509
  """Start or verify proxy. Returns process handle if we started it."""
510
  if not no_proxy:
511
+ manifest = _find_persistent_manifest(port)
512
+ if manifest is not None:
513
+ from headroom.install.health import probe_ready
514
+
515
+ if probe_ready(manifest.health_url):
516
+ click.echo(f" Proxy already running on port {port}")
517
+ return None
518
+ if _recover_persistent_proxy(port):
519
+ return None
520
+
521
  if _check_proxy(port):
522
  click.echo(f" Proxy already running on port {port}")
523
  return None
headroom/install/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persistent install / deployment helpers for Headroom."""
2
+
3
+ from .models import (
4
+ ConfigScope,
5
+ DeploymentManifest,
6
+ InstallPreset,
7
+ ProviderSelectionMode,
8
+ SupervisorKind,
9
+ ToolTarget,
10
+ )
11
+
12
+ __all__ = [
13
+ "ConfigScope",
14
+ "DeploymentManifest",
15
+ "InstallPreset",
16
+ "ProviderSelectionMode",
17
+ "SupervisorKind",
18
+ "ToolTarget",
19
+ ]
headroom/install/health.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Health helpers for persistent deployments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.error
7
+ import urllib.request
8
+ from typing import Any
9
+
10
+
11
+ def probe_json(url: str, timeout: float = 2.0) -> dict[str, Any] | None:
12
+ """Return a JSON payload from the URL when reachable."""
13
+
14
+ try:
15
+ with urllib.request.urlopen(url, timeout=timeout) as response:
16
+ payload = json.loads(response.read().decode("utf-8"))
17
+ except (OSError, urllib.error.URLError, ValueError, json.JSONDecodeError):
18
+ return None
19
+ return payload if isinstance(payload, dict) else None
20
+
21
+
22
+ def probe_ready(url: str, timeout: float = 2.0) -> bool:
23
+ """Return True when the ready endpoint reports readiness."""
24
+
25
+ payload = probe_json(url, timeout=timeout)
26
+ if not isinstance(payload, dict):
27
+ return False
28
+ return bool(payload.get("ready", False) or payload.get("status") == "healthy")
headroom/install/models.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Models used by the install / deployment subsystem."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timezone
7
+ from enum import Enum
8
+ from typing import Any
9
+
10
+
11
+ class InstallPreset(str, Enum):
12
+ """User-facing persistent runtime presets."""
13
+
14
+ PERSISTENT_SERVICE = "persistent-service"
15
+ PERSISTENT_TASK = "persistent-task"
16
+ PERSISTENT_DOCKER = "persistent-docker"
17
+
18
+
19
+ class RuntimeKind(str, Enum):
20
+ """Runtime used to execute Headroom."""
21
+
22
+ PYTHON = "python"
23
+ DOCKER = "docker"
24
+
25
+
26
+ class SupervisorKind(str, Enum):
27
+ """How a persistent deployment is kept alive."""
28
+
29
+ SERVICE = "service"
30
+ TASK = "task"
31
+ NONE = "none"
32
+
33
+
34
+ class ProviderSelectionMode(str, Enum):
35
+ """How tool targets are selected for configuration."""
36
+
37
+ AUTO = "auto"
38
+ ALL = "all"
39
+ MANUAL = "manual"
40
+
41
+
42
+ class ConfigScope(str, Enum):
43
+ """Where persistent configuration should be applied."""
44
+
45
+ PROVIDER = "provider"
46
+ USER = "user"
47
+ SYSTEM = "system"
48
+
49
+
50
+ class ToolTarget(str, Enum):
51
+ """Supported tool targets for persistent proxy wiring."""
52
+
53
+ CLAUDE = "claude"
54
+ COPILOT = "copilot"
55
+ CODEX = "codex"
56
+ AIDER = "aider"
57
+ CURSOR = "cursor"
58
+ OPENCLAW = "openclaw"
59
+
60
+
61
+ def iso_utc_now() -> str:
62
+ """Return the current UTC timestamp in ISO-8601 format."""
63
+
64
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
65
+
66
+
67
+ @dataclass
68
+ class ManagedMutation:
69
+ """A reversible change applied by `headroom install`."""
70
+
71
+ target: str
72
+ kind: str
73
+ path: str | None = None
74
+ data: dict[str, Any] = field(default_factory=dict)
75
+
76
+
77
+ @dataclass
78
+ class ArtifactRecord:
79
+ """A rendered file or platform object owned by the deployment."""
80
+
81
+ kind: str
82
+ path: str
83
+ metadata: dict[str, Any] = field(default_factory=dict)
84
+
85
+
86
+ @dataclass
87
+ class DeploymentManifest:
88
+ """Persisted deployment state for a named profile."""
89
+
90
+ profile: str
91
+ preset: str
92
+ runtime_kind: str
93
+ supervisor_kind: str
94
+ scope: str
95
+ provider_mode: str
96
+ targets: list[str]
97
+ port: int
98
+ host: str
99
+ backend: str
100
+ anyllm_provider: str | None = None
101
+ region: str | None = None
102
+ proxy_mode: str = "token"
103
+ memory_enabled: bool = False
104
+ memory_db_path: str = ""
105
+ telemetry_enabled: bool = True
106
+ image: str = "ghcr.io/chopratejas/headroom:latest"
107
+ service_name: str = "headroom"
108
+ container_name: str = "headroom-persistent"
109
+ health_url: str = "http://127.0.0.1:8787/readyz"
110
+ base_env: dict[str, str] = field(default_factory=dict)
111
+ tool_envs: dict[str, dict[str, str]] = field(default_factory=dict)
112
+ proxy_args: list[str] = field(default_factory=list)
113
+ mutations: list[ManagedMutation] = field(default_factory=list)
114
+ artifacts: list[ArtifactRecord] = field(default_factory=list)
115
+ created_at: str = field(default_factory=iso_utc_now)
116
+ updated_at: str = field(default_factory=iso_utc_now)
headroom/install/paths.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Path helpers for persistent deployments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ def deploy_root() -> Path:
10
+ """Return the root directory for deployment state."""
11
+
12
+ return Path.home() / ".headroom" / "deploy"
13
+
14
+
15
+ def profile_root(profile: str) -> Path:
16
+ """Return the directory for a named deployment profile."""
17
+
18
+ return deploy_root() / profile
19
+
20
+
21
+ def manifest_path(profile: str) -> Path:
22
+ """Return the manifest path for a named profile."""
23
+
24
+ return profile_root(profile) / "manifest.json"
25
+
26
+
27
+ def log_path(profile: str) -> Path:
28
+ """Return the log path used by persistent runner scripts."""
29
+
30
+ return profile_root(profile) / "runner.log"
31
+
32
+
33
+ def pid_path(profile: str) -> Path:
34
+ """Return the pid file for the raw runtime process."""
35
+
36
+ return profile_root(profile) / "runner.pid"
37
+
38
+
39
+ def unix_run_script_path(profile: str) -> Path:
40
+ """Return the foreground runner shell script path."""
41
+
42
+ return profile_root(profile) / "run-headroom.sh"
43
+
44
+
45
+ def unix_ensure_script_path(profile: str) -> Path:
46
+ """Return the watchdog shell script path."""
47
+
48
+ return profile_root(profile) / "ensure-headroom.sh"
49
+
50
+
51
+ def windows_run_script_path(profile: str) -> Path:
52
+ """Return the foreground runner PowerShell script path."""
53
+
54
+ return profile_root(profile) / "run-headroom.ps1"
55
+
56
+
57
+ def windows_run_cmd_path(profile: str) -> Path:
58
+ """Return the foreground runner CMD shim path."""
59
+
60
+ return profile_root(profile) / "run-headroom.cmd"
61
+
62
+
63
+ def windows_ensure_script_path(profile: str) -> Path:
64
+ """Return the watchdog PowerShell script path."""
65
+
66
+ return profile_root(profile) / "ensure-headroom.ps1"
67
+
68
+
69
+ def windows_ensure_cmd_path(profile: str) -> Path:
70
+ """Return the watchdog CMD shim path."""
71
+
72
+ return profile_root(profile) / "ensure-headroom.cmd"
73
+
74
+
75
+ def unix_user_env_targets() -> list[Path]:
76
+ """Return user shell files that can carry the persistent env block."""
77
+
78
+ home = Path.home()
79
+ return [home / ".bashrc", home / ".zshrc", home / ".profile"]
80
+
81
+
82
+ def unix_system_env_targets() -> list[Path]:
83
+ """Return system shell files that can carry the persistent env block."""
84
+
85
+ if sys.platform == "darwin":
86
+ return [Path("/etc/profile"), Path("/etc/zprofile"), Path("/etc/bashrc")]
87
+ return [Path("/etc/profile.d/headroom.sh")]
88
+
89
+
90
+ def claude_settings_path() -> Path:
91
+ """Return the Claude user settings path."""
92
+
93
+ return Path.home() / ".claude" / "settings.json"
94
+
95
+
96
+ def codex_config_path() -> Path:
97
+ """Return the Codex config path."""
98
+
99
+ return Path.home() / ".codex" / "config.toml"
100
+
101
+
102
+ def openclaw_config_path() -> Path:
103
+ """Return the OpenClaw config path."""
104
+
105
+ return Path.home() / ".openclaw" / "openclaw.json"
headroom/install/planner.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Planner for persistent deployment manifests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from collections.abc import Iterable
7
+ from pathlib import Path
8
+
9
+ from .models import (
10
+ DeploymentManifest,
11
+ InstallPreset,
12
+ ProviderSelectionMode,
13
+ SupervisorKind,
14
+ ToolTarget,
15
+ )
16
+
17
+ SUPPORTED_TARGETS = [
18
+ ToolTarget.CLAUDE,
19
+ ToolTarget.COPILOT,
20
+ ToolTarget.CODEX,
21
+ ToolTarget.AIDER,
22
+ ToolTarget.CURSOR,
23
+ ToolTarget.OPENCLAW,
24
+ ]
25
+
26
+
27
+ def _binary_name(target: ToolTarget) -> str | None:
28
+ if target == ToolTarget.CURSOR:
29
+ return None
30
+ return str(target.value)
31
+
32
+
33
+ def detect_targets() -> list[str]:
34
+ """Auto-detect available tool targets on the current host."""
35
+
36
+ detected: list[str] = []
37
+ for target in SUPPORTED_TARGETS:
38
+ binary = _binary_name(target)
39
+ if binary and shutil.which(binary):
40
+ detected.append(target.value)
41
+ continue
42
+ if target == ToolTarget.CURSOR and shutil.which("cursor"):
43
+ detected.append(target.value)
44
+ return detected
45
+
46
+
47
+ def resolve_targets(provider_mode: str, requested_targets: Iterable[str]) -> list[str]:
48
+ """Resolve target selection according to the requested provider mode."""
49
+
50
+ if provider_mode == ProviderSelectionMode.ALL.value:
51
+ return [target.value for target in SUPPORTED_TARGETS]
52
+
53
+ if provider_mode == ProviderSelectionMode.AUTO.value:
54
+ detected = detect_targets()
55
+ return detected or [
56
+ ToolTarget.CLAUDE.value,
57
+ ToolTarget.CODEX.value,
58
+ ToolTarget.COPILOT.value,
59
+ ]
60
+
61
+ normalized = []
62
+ seen: set[str] = set()
63
+ valid = {target.value for target in SUPPORTED_TARGETS}
64
+ for target in requested_targets:
65
+ value = target.strip().lower()
66
+ if value in valid and value not in seen:
67
+ seen.add(value)
68
+ normalized.append(value)
69
+ return normalized
70
+
71
+
72
+ def _copilot_env(port: int, backend: str) -> dict[str, str]:
73
+ if backend == "anthropic":
74
+ return {
75
+ "COPILOT_PROVIDER_TYPE": "anthropic",
76
+ "COPILOT_PROVIDER_BASE_URL": f"http://127.0.0.1:{port}",
77
+ }
78
+ return {
79
+ "COPILOT_PROVIDER_TYPE": "openai",
80
+ "COPILOT_PROVIDER_BASE_URL": f"http://127.0.0.1:{port}/v1",
81
+ "COPILOT_PROVIDER_WIRE_API": "completions",
82
+ }
83
+
84
+
85
+ def build_tool_envs(port: int, backend: str, targets: list[str]) -> dict[str, dict[str, str]]:
86
+ """Build per-target environment variables for the selected tools."""
87
+
88
+ target_envs: dict[str, dict[str, str]] = {}
89
+ if ToolTarget.CLAUDE.value in targets:
90
+ target_envs[ToolTarget.CLAUDE.value] = {
91
+ "ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
92
+ }
93
+ if ToolTarget.CODEX.value in targets:
94
+ target_envs[ToolTarget.CODEX.value] = {
95
+ "OPENAI_BASE_URL": f"http://127.0.0.1:{port}/v1",
96
+ }
97
+ if ToolTarget.AIDER.value in targets:
98
+ target_envs[ToolTarget.AIDER.value] = {
99
+ "OPENAI_API_BASE": f"http://127.0.0.1:{port}/v1",
100
+ "ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
101
+ }
102
+ if ToolTarget.COPILOT.value in targets:
103
+ target_envs[ToolTarget.COPILOT.value] = _copilot_env(port, backend)
104
+ if ToolTarget.CURSOR.value in targets:
105
+ target_envs[ToolTarget.CURSOR.value] = {
106
+ "OPENAI_BASE_URL": f"http://127.0.0.1:{port}/v1",
107
+ "ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
108
+ }
109
+ return target_envs
110
+
111
+
112
+ def build_manifest(
113
+ *,
114
+ profile: str,
115
+ preset: str,
116
+ runtime_kind: str,
117
+ scope: str,
118
+ provider_mode: str,
119
+ targets: list[str],
120
+ port: int,
121
+ backend: str,
122
+ anyllm_provider: str | None,
123
+ region: str | None,
124
+ proxy_mode: str,
125
+ memory_enabled: bool,
126
+ telemetry_enabled: bool,
127
+ image: str,
128
+ ) -> DeploymentManifest:
129
+ """Create a normalized deployment manifest."""
130
+
131
+ if preset == InstallPreset.PERSISTENT_SERVICE.value:
132
+ supervisor_kind = SupervisorKind.SERVICE.value
133
+ elif preset == InstallPreset.PERSISTENT_TASK.value:
134
+ supervisor_kind = SupervisorKind.TASK.value
135
+ else:
136
+ supervisor_kind = SupervisorKind.NONE.value
137
+
138
+ resolved_targets = resolve_targets(provider_mode, targets)
139
+ tool_envs = build_tool_envs(port, backend, resolved_targets)
140
+ base_env = {
141
+ "HEADROOM_PORT": str(port),
142
+ "HEADROOM_HOST": "127.0.0.1",
143
+ "HEADROOM_MODE": proxy_mode,
144
+ "HEADROOM_BACKEND": backend,
145
+ }
146
+ if anyllm_provider:
147
+ base_env["HEADROOM_ANYLLM_PROVIDER"] = anyllm_provider
148
+ if region:
149
+ base_env["HEADROOM_REGION"] = region
150
+ if not telemetry_enabled:
151
+ base_env["HEADROOM_TELEMETRY"] = "off"
152
+ if memory_enabled:
153
+ base_env["HEADROOM_MEMORY_ENABLED"] = "1"
154
+
155
+ proxy_args = [
156
+ "--host",
157
+ "127.0.0.1",
158
+ "--port",
159
+ str(port),
160
+ "--mode",
161
+ proxy_mode,
162
+ "--backend",
163
+ backend,
164
+ ]
165
+ if not telemetry_enabled:
166
+ proxy_args.append("--no-telemetry")
167
+ if memory_enabled:
168
+ proxy_args.extend(
169
+ ["--memory", "--memory-db-path", str(Path.home() / ".headroom" / "memory.db")]
170
+ )
171
+ if anyllm_provider:
172
+ proxy_args.extend(["--anyllm-provider", anyllm_provider])
173
+ if region:
174
+ proxy_args.extend(["--region", region])
175
+
176
+ container_name = f"headroom-{profile}"
177
+ return DeploymentManifest(
178
+ profile=profile,
179
+ preset=preset,
180
+ runtime_kind=runtime_kind,
181
+ supervisor_kind=supervisor_kind,
182
+ scope=scope,
183
+ provider_mode=provider_mode,
184
+ targets=resolved_targets,
185
+ port=port,
186
+ host="127.0.0.1",
187
+ backend=backend,
188
+ anyllm_provider=anyllm_provider,
189
+ region=region,
190
+ proxy_mode=proxy_mode,
191
+ memory_enabled=memory_enabled,
192
+ memory_db_path=str(Path.home() / ".headroom" / "memory.db"),
193
+ telemetry_enabled=telemetry_enabled,
194
+ image=image,
195
+ service_name=f"headroom-{profile}",
196
+ container_name=container_name,
197
+ health_url=f"http://127.0.0.1:{port}/readyz",
198
+ base_env=base_env,
199
+ tool_envs=tool_envs,
200
+ proxy_args=proxy_args,
201
+ )
headroom/install/providers.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tool-target configuration for persistent deployments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ import click
12
+
13
+ from .models import ConfigScope, DeploymentManifest, ManagedMutation, ToolTarget
14
+ from .paths import (
15
+ claude_settings_path,
16
+ codex_config_path,
17
+ openclaw_config_path,
18
+ unix_system_env_targets,
19
+ unix_user_env_targets,
20
+ )
21
+ from .runtime import resolve_headroom_command
22
+
23
+ _ENV_MARKER_START = "# >>> headroom persistent env >>>"
24
+ _ENV_MARKER_END = "# <<< headroom persistent env <<<"
25
+ _ENV_PATTERN = re.compile(
26
+ re.escape(_ENV_MARKER_START) + r".*?" + re.escape(_ENV_MARKER_END),
27
+ re.DOTALL,
28
+ )
29
+ _CODEX_MARKER_START = "# --- Headroom persistent provider ---"
30
+ _CODEX_MARKER_END = "# --- end Headroom persistent provider ---"
31
+ _CODEX_PATTERN = re.compile(
32
+ re.escape(_CODEX_MARKER_START) + r".*?" + re.escape(_CODEX_MARKER_END),
33
+ re.DOTALL,
34
+ )
35
+
36
+
37
+ def _merge_marker_block(file_path: Path, block: str, pattern: re.Pattern[str], marker: str) -> str:
38
+ if file_path.exists():
39
+ existing = file_path.read_text()
40
+ if marker in existing:
41
+ return pattern.sub(block, existing)
42
+ return existing.rstrip() + "\n\n" + block + "\n"
43
+ return block + "\n"
44
+
45
+
46
+ def _env_block(values: dict[str, str]) -> str:
47
+ lines = [_ENV_MARKER_START]
48
+ for name, value in values.items():
49
+ lines.append(f'export {name}="{value}"')
50
+ lines.append(_ENV_MARKER_END)
51
+ return "\n".join(lines)
52
+
53
+
54
+ def _unix_scope_values(manifest: DeploymentManifest) -> dict[str, str]:
55
+ merged = dict(manifest.base_env)
56
+ for env_map in manifest.tool_envs.values():
57
+ merged.update(env_map)
58
+ return merged
59
+
60
+
61
+ def _apply_unix_env_scope(manifest: DeploymentManifest) -> list[ManagedMutation]:
62
+ values = _unix_scope_values(manifest)
63
+ block = _env_block(values)
64
+ if manifest.scope == ConfigScope.USER.value:
65
+ targets = unix_user_env_targets()
66
+ else:
67
+ targets = unix_system_env_targets()
68
+ mutations: list[ManagedMutation] = []
69
+ for path in targets:
70
+ path.parent.mkdir(parents=True, exist_ok=True)
71
+ merged = _merge_marker_block(path, block, _ENV_PATTERN, _ENV_MARKER_START)
72
+ path.write_text(merged)
73
+ mutations.append(ManagedMutation(target="env", kind="shell-block", path=str(path)))
74
+ return mutations
75
+
76
+
77
+ def _remove_unix_env_scope(mutations: list[ManagedMutation]) -> None:
78
+ for mutation in mutations:
79
+ if mutation.kind != "shell-block" or not mutation.path:
80
+ continue
81
+ path = Path(mutation.path)
82
+ if not path.exists():
83
+ continue
84
+ content = path.read_text()
85
+ if _ENV_MARKER_START not in content:
86
+ continue
87
+ path.write_text(_ENV_PATTERN.sub("", content).strip() + "\n")
88
+
89
+
90
+ def _apply_windows_env_scope(manifest: DeploymentManifest) -> list[ManagedMutation]:
91
+ scope_name = "Machine" if manifest.scope == ConfigScope.SYSTEM.value else "User"
92
+ merged = _unix_scope_values(manifest)
93
+ mutations: list[ManagedMutation] = []
94
+ for name, value in merged.items():
95
+ command = [
96
+ "powershell",
97
+ "-NoProfile",
98
+ "-Command",
99
+ f"[Environment]::SetEnvironmentVariable('{name}','{value}','{scope_name}')",
100
+ ]
101
+ subprocess.run(command, check=True)
102
+ mutations.append(
103
+ ManagedMutation(
104
+ target="env", kind="windows-env", data={"name": name, "scope": scope_name}
105
+ )
106
+ )
107
+ return mutations
108
+
109
+
110
+ def _remove_windows_env_scope(mutations: list[ManagedMutation]) -> None:
111
+ for mutation in mutations:
112
+ if mutation.kind != "windows-env":
113
+ continue
114
+ name = mutation.data.get("name")
115
+ scope_name = mutation.data.get("scope", "User")
116
+ command = [
117
+ "powershell",
118
+ "-NoProfile",
119
+ "-Command",
120
+ f"[Environment]::SetEnvironmentVariable('{name}',$null,'{scope_name}')",
121
+ ]
122
+ subprocess.run(command, check=True)
123
+
124
+
125
+ def _apply_claude_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
126
+ path = claude_settings_path()
127
+ path.parent.mkdir(parents=True, exist_ok=True)
128
+ payload: dict[str, object] = {}
129
+ if path.exists():
130
+ payload = json.loads(path.read_text())
131
+ env = payload.get("env")
132
+ env_map = dict(env) if isinstance(env, dict) else {}
133
+ previous = {
134
+ name: env_map.get(name) for name in manifest.tool_envs.get(ToolTarget.CLAUDE.value, {})
135
+ }
136
+ env_map.update(manifest.tool_envs[ToolTarget.CLAUDE.value])
137
+ payload["env"] = env_map
138
+ path.write_text(json.dumps(payload, indent=2) + "\n")
139
+ return ManagedMutation(
140
+ target=ToolTarget.CLAUDE.value,
141
+ kind="json-env",
142
+ path=str(path),
143
+ data={"previous": previous},
144
+ )
145
+
146
+
147
+ def _revert_claude_provider_scope(mutation: ManagedMutation, values: dict[str, str]) -> None:
148
+ if not mutation.path:
149
+ return
150
+ path = Path(mutation.path)
151
+ if not path.exists():
152
+ return
153
+ payload = json.loads(path.read_text())
154
+ env = payload.get("env")
155
+ env_map = dict(env) if isinstance(env, dict) else {}
156
+ previous: dict[str, object] = mutation.data.get("previous", {})
157
+ for name in values:
158
+ if previous.get(name) is None:
159
+ env_map.pop(name, None)
160
+ else:
161
+ env_map[name] = previous[name]
162
+ payload["env"] = env_map
163
+ path.write_text(json.dumps(payload, indent=2) + "\n")
164
+
165
+
166
+ def _apply_codex_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
167
+ path = codex_config_path()
168
+ path.parent.mkdir(parents=True, exist_ok=True)
169
+ section = (
170
+ f"{_CODEX_MARKER_START}\n"
171
+ 'model_provider = "headroom"\n\n'
172
+ "[model_providers.headroom]\n"
173
+ 'name = "Headroom persistent proxy"\n'
174
+ f'base_url = "http://127.0.0.1:{manifest.port}/v1"\n'
175
+ 'env_key = "OPENAI_API_KEY"\n'
176
+ "requires_openai_auth = true\n"
177
+ "supports_websockets = true\n"
178
+ f"{_CODEX_MARKER_END}\n"
179
+ )
180
+ merged = _merge_marker_block(path, section, _CODEX_PATTERN, _CODEX_MARKER_START)
181
+ path.write_text(merged)
182
+ return ManagedMutation(target=ToolTarget.CODEX.value, kind="toml-block", path=str(path))
183
+
184
+
185
+ def _revert_codex_provider_scope(mutation: ManagedMutation) -> None:
186
+ if not mutation.path:
187
+ return
188
+ path = Path(mutation.path)
189
+ if not path.exists():
190
+ return
191
+ content = path.read_text()
192
+ if _CODEX_MARKER_START not in content:
193
+ return
194
+ path.write_text(_CODEX_PATTERN.sub("", content).strip() + "\n")
195
+
196
+
197
+ def _invoke_openclaw(command: list[str]) -> None:
198
+ subprocess.run(command, check=True)
199
+
200
+
201
+ def _apply_openclaw_provider_scope(manifest: DeploymentManifest) -> ManagedMutation:
202
+ if not shutil_which("openclaw"):
203
+ raise click.ClickException("openclaw not found in PATH; cannot apply provider scope.")
204
+ command = [
205
+ *resolve_headroom_command(),
206
+ "wrap",
207
+ "openclaw",
208
+ "--no-auto-start",
209
+ "--proxy-port",
210
+ str(manifest.port),
211
+ ]
212
+ _invoke_openclaw(command)
213
+ return ManagedMutation(
214
+ target=ToolTarget.OPENCLAW.value, kind="openclaw-wrap", path=str(openclaw_config_path())
215
+ )
216
+
217
+
218
+ def _revert_openclaw_provider_scope() -> None:
219
+ if not shutil_which("openclaw"):
220
+ return
221
+ command = [*resolve_headroom_command(), "unwrap", "openclaw"]
222
+ _invoke_openclaw(command)
223
+
224
+
225
+ def shutil_which(name: str) -> str | None:
226
+ from shutil import which
227
+
228
+ return which(name)
229
+
230
+
231
+ def apply_mutations(manifest: DeploymentManifest) -> list[ManagedMutation]:
232
+ """Apply provider/user/system configuration for a deployment."""
233
+
234
+ mutations: list[ManagedMutation] = []
235
+ if manifest.scope in {ConfigScope.USER.value, ConfigScope.SYSTEM.value}:
236
+ if os.name == "nt":
237
+ mutations.extend(_apply_windows_env_scope(manifest))
238
+ else:
239
+ mutations.extend(_apply_unix_env_scope(manifest))
240
+ if ToolTarget.OPENCLAW.value in manifest.targets:
241
+ try:
242
+ mutations.append(_apply_openclaw_provider_scope(manifest))
243
+ except click.ClickException:
244
+ pass
245
+ return mutations
246
+
247
+ if ToolTarget.CLAUDE.value in manifest.targets:
248
+ mutations.append(_apply_claude_provider_scope(manifest))
249
+ if ToolTarget.CODEX.value in manifest.targets:
250
+ mutations.append(_apply_codex_provider_scope(manifest))
251
+ if ToolTarget.OPENCLAW.value in manifest.targets:
252
+ try:
253
+ mutations.append(_apply_openclaw_provider_scope(manifest))
254
+ except click.ClickException:
255
+ pass
256
+ return mutations
257
+
258
+
259
+ def revert_mutations(manifest: DeploymentManifest) -> None:
260
+ """Undo the stored mutations for a deployment."""
261
+
262
+ if manifest.scope in {ConfigScope.USER.value, ConfigScope.SYSTEM.value}:
263
+ shell_mutations = [m for m in manifest.mutations if m.target == "env"]
264
+ if os.name == "nt":
265
+ _remove_windows_env_scope(shell_mutations)
266
+ else:
267
+ _remove_unix_env_scope(shell_mutations)
268
+
269
+ for mutation in manifest.mutations:
270
+ if mutation.target == ToolTarget.CLAUDE.value and mutation.kind == "json-env":
271
+ _revert_claude_provider_scope(
272
+ mutation, manifest.tool_envs.get(ToolTarget.CLAUDE.value, {})
273
+ )
274
+ elif mutation.target == ToolTarget.CODEX.value and mutation.kind == "toml-block":
275
+ _revert_codex_provider_scope(mutation)
276
+ elif mutation.target == ToolTarget.OPENCLAW.value and mutation.kind == "openclaw-wrap":
277
+ _revert_openclaw_provider_scope()
headroom/install/runtime.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runtime helpers for persistent deployments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .health import probe_ready
15
+ from .models import DeploymentManifest, InstallPreset, RuntimeKind
16
+ from .paths import log_path, pid_path
17
+
18
+
19
+ def _deployment_env(manifest: DeploymentManifest) -> dict[str, str]:
20
+ return {
21
+ "HEADROOM_DEPLOYMENT_PROFILE": manifest.profile,
22
+ "HEADROOM_DEPLOYMENT_PRESET": manifest.preset,
23
+ "HEADROOM_DEPLOYMENT_RUNTIME": manifest.runtime_kind,
24
+ "HEADROOM_DEPLOYMENT_SUPERVISOR": manifest.supervisor_kind,
25
+ "HEADROOM_DEPLOYMENT_SCOPE": manifest.scope,
26
+ }
27
+
28
+
29
+ def resolve_headroom_command() -> list[str]:
30
+ """Resolve the most reliable command to invoke headroom."""
31
+
32
+ headroom_bin = shutil.which("headroom")
33
+ if headroom_bin:
34
+ return [headroom_bin]
35
+ return [sys.executable, "-m", "headroom.cli"]
36
+
37
+
38
+ def _runtime_env(manifest: DeploymentManifest) -> dict[str, str]:
39
+ env = os.environ.copy()
40
+ env.update(manifest.base_env)
41
+ env.update(_deployment_env(manifest))
42
+ return env
43
+
44
+
45
+ def build_runtime_command(manifest: DeploymentManifest) -> list[str]:
46
+ """Build the raw foreground command that runs the proxy."""
47
+
48
+ if manifest.runtime_kind == RuntimeKind.PYTHON.value:
49
+ return [sys.executable, "-m", "headroom.cli", "proxy", *manifest.proxy_args]
50
+
51
+ home = str(Path.home())
52
+ container_home = "/tmp/headroom-home"
53
+ command = [
54
+ "docker",
55
+ "run",
56
+ "--rm",
57
+ "--name",
58
+ manifest.container_name,
59
+ "-p",
60
+ f"127.0.0.1:{manifest.port}:{manifest.port}",
61
+ "--workdir",
62
+ container_home,
63
+ "--env",
64
+ f"HOME={container_home}",
65
+ "--env",
66
+ "PYTHONUNBUFFERED=1",
67
+ "--volume",
68
+ f"{home}\\.headroom:{container_home}/.headroom"
69
+ if os.name == "nt"
70
+ else f"{home}/.headroom:{container_home}/.headroom",
71
+ "--volume",
72
+ f"{home}\\.claude:{container_home}/.claude"
73
+ if os.name == "nt"
74
+ else f"{home}/.claude:{container_home}/.claude",
75
+ "--volume",
76
+ f"{home}\\.codex:{container_home}/.codex"
77
+ if os.name == "nt"
78
+ else f"{home}/.codex:{container_home}/.codex",
79
+ "--volume",
80
+ f"{home}\\.gemini:{container_home}/.gemini"
81
+ if os.name == "nt"
82
+ else f"{home}/.gemini:{container_home}/.gemini",
83
+ ]
84
+ runtime_env = {**manifest.base_env, **_deployment_env(manifest)}
85
+ for name, value in runtime_env.items():
86
+ command.extend(["--env", f"{name}={value}"])
87
+ command.extend(
88
+ [
89
+ manifest.image,
90
+ "headroom",
91
+ "proxy",
92
+ "--host",
93
+ "0.0.0.0",
94
+ *manifest.proxy_args[2:],
95
+ ]
96
+ )
97
+ return command
98
+
99
+
100
+ def _write_pid(profile: str, pid: int) -> None:
101
+ path = pid_path(profile)
102
+ path.parent.mkdir(parents=True, exist_ok=True)
103
+ path.write_text(str(pid))
104
+
105
+
106
+ def _read_pid(profile: str) -> int | None:
107
+ path = pid_path(profile)
108
+ if not path.exists():
109
+ return None
110
+ try:
111
+ return int(path.read_text().strip())
112
+ except ValueError:
113
+ return None
114
+
115
+
116
+ def _clear_pid(profile: str) -> None:
117
+ path = pid_path(profile)
118
+ if path.exists():
119
+ path.unlink()
120
+
121
+
122
+ def run_foreground(manifest: DeploymentManifest) -> int:
123
+ """Run the raw runtime command in the foreground."""
124
+
125
+ command = build_runtime_command(manifest)
126
+ env = _runtime_env(manifest)
127
+ log_file_path = log_path(manifest.profile)
128
+ log_file_path.parent.mkdir(parents=True, exist_ok=True)
129
+
130
+ with open(log_file_path, "a", encoding="utf-8", errors="replace") as log_file:
131
+ proc = subprocess.Popen(command, env=env, stdout=log_file, stderr=log_file)
132
+ _write_pid(manifest.profile, proc.pid)
133
+
134
+ def _cleanup(signum: int | None = None, frame: Any = None) -> None:
135
+ if proc.poll() is None:
136
+ proc.terminate()
137
+ try:
138
+ proc.wait(timeout=10)
139
+ except subprocess.TimeoutExpired:
140
+ proc.kill()
141
+
142
+ signal.signal(signal.SIGINT, _cleanup)
143
+ signal.signal(signal.SIGTERM, _cleanup)
144
+ try:
145
+ return proc.wait()
146
+ finally:
147
+ _clear_pid(manifest.profile)
148
+
149
+
150
+ def start_detached_agent(profile: str) -> subprocess.Popen[str]:
151
+ """Start `headroom install agent run` detached for the given profile."""
152
+
153
+ command = [*resolve_headroom_command(), "install", "agent", "run", "--profile", profile]
154
+ log_file_path = log_path(profile)
155
+ log_file_path.parent.mkdir(parents=True, exist_ok=True)
156
+ log_file = open(log_file_path, "a", encoding="utf-8", errors="replace") # noqa: SIM115
157
+
158
+ kwargs: dict[str, Any] = {"stdout": log_file, "stderr": log_file}
159
+ if os.name == "nt":
160
+ kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
161
+ subprocess, "CREATE_NEW_PROCESS_GROUP", 0
162
+ )
163
+ else:
164
+ kwargs["start_new_session"] = True
165
+ return subprocess.Popen(command, **kwargs)
166
+
167
+
168
+ def start_persistent_docker(manifest: DeploymentManifest) -> None:
169
+ """Start a persistent Docker container with restart policy."""
170
+
171
+ command = build_runtime_command(manifest)
172
+ docker_cmd = [
173
+ "docker",
174
+ "run",
175
+ "-d",
176
+ "--restart",
177
+ "unless-stopped",
178
+ "--name",
179
+ manifest.container_name,
180
+ *command[5:], # drop initial `docker run --rm --name ...`
181
+ ]
182
+ subprocess.run(["docker", "rm", "-f", manifest.container_name], capture_output=True, text=True)
183
+ subprocess.run(docker_cmd, check=True)
184
+
185
+
186
+ def stop_runtime(manifest: DeploymentManifest) -> None:
187
+ """Stop the raw runtime for the deployment."""
188
+
189
+ if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
190
+ subprocess.run(["docker", "stop", manifest.container_name], capture_output=True, text=True)
191
+ subprocess.run(
192
+ ["docker", "rm", "-f", manifest.container_name], capture_output=True, text=True
193
+ )
194
+ return
195
+
196
+ pid = _read_pid(manifest.profile)
197
+ if pid is None:
198
+ return
199
+ try:
200
+ os.kill(pid, signal.SIGTERM)
201
+ except OSError:
202
+ pass
203
+ _clear_pid(manifest.profile)
204
+
205
+
206
+ def wait_ready(manifest: DeploymentManifest, timeout_seconds: int = 30) -> bool:
207
+ """Wait for the deployment to report ready."""
208
+
209
+ for _ in range(timeout_seconds):
210
+ if probe_ready(manifest.health_url):
211
+ return True
212
+ time.sleep(1)
213
+ return False
214
+
215
+
216
+ def runtime_status(manifest: DeploymentManifest) -> str:
217
+ """Return a short status string for the deployment runtime."""
218
+
219
+ if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value:
220
+ result = subprocess.run(
221
+ ["docker", "ps", "--format", "{{.Names}}"], capture_output=True, text=True
222
+ )
223
+ if manifest.container_name in result.stdout.splitlines():
224
+ return "running"
225
+ return "stopped"
226
+ pid = _read_pid(manifest.profile)
227
+ if pid is None:
228
+ return "stopped"
229
+ try:
230
+ os.kill(pid, 0)
231
+ except OSError:
232
+ return "stopped"
233
+ return "running"
headroom/install/state.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persistence helpers for deployment manifests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict
7
+
8
+ from .models import ArtifactRecord, DeploymentManifest, ManagedMutation, iso_utc_now
9
+ from .paths import deploy_root, manifest_path, profile_root
10
+
11
+
12
+ def save_manifest(manifest: DeploymentManifest) -> None:
13
+ """Persist a deployment manifest to disk."""
14
+
15
+ root = profile_root(manifest.profile)
16
+ root.mkdir(parents=True, exist_ok=True)
17
+ manifest.updated_at = iso_utc_now()
18
+ path = manifest_path(manifest.profile)
19
+ path.write_text(json.dumps(asdict(manifest), indent=2) + "\n")
20
+
21
+
22
+ def load_manifest(profile: str = "default") -> DeploymentManifest | None:
23
+ """Load a deployment manifest when present."""
24
+
25
+ path = manifest_path(profile)
26
+ if not path.exists():
27
+ return None
28
+ payload = json.loads(path.read_text())
29
+ payload["mutations"] = [ManagedMutation(**item) for item in payload.get("mutations", [])]
30
+ payload["artifacts"] = [ArtifactRecord(**item) for item in payload.get("artifacts", [])]
31
+ return DeploymentManifest(**payload)
32
+
33
+
34
+ def list_manifests() -> list[DeploymentManifest]:
35
+ """Load all deployment manifests under the deployment root."""
36
+
37
+ root = deploy_root()
38
+ if not root.exists():
39
+ return []
40
+
41
+ manifests: list[DeploymentManifest] = []
42
+ for candidate in sorted(root.glob("*/manifest.json")):
43
+ try:
44
+ payload = json.loads(candidate.read_text())
45
+ payload["mutations"] = [
46
+ ManagedMutation(**item) for item in payload.get("mutations", [])
47
+ ]
48
+ payload["artifacts"] = [ArtifactRecord(**item) for item in payload.get("artifacts", [])]
49
+ manifests.append(DeploymentManifest(**payload))
50
+ except (OSError, ValueError, TypeError):
51
+ continue
52
+ return manifests
53
+
54
+
55
+ def delete_manifest(profile: str = "default") -> None:
56
+ """Delete the deployment manifest if present."""
57
+
58
+ path = manifest_path(profile)
59
+ if path.exists():
60
+ path.unlink()
headroom/install/supervisors.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Supervisor installation helpers for persistent deployments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shlex
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import click
13
+
14
+ from .models import ArtifactRecord, DeploymentManifest, SupervisorKind
15
+ from .paths import (
16
+ unix_ensure_script_path,
17
+ unix_run_script_path,
18
+ windows_ensure_cmd_path,
19
+ windows_ensure_script_path,
20
+ windows_run_cmd_path,
21
+ windows_run_script_path,
22
+ )
23
+ from .runtime import resolve_headroom_command
24
+
25
+
26
+ def _command_for_script(*parts: str) -> list[str]:
27
+ return [*resolve_headroom_command(), *parts]
28
+
29
+
30
+ def _render_unix_runner(path: Path, command: list[str]) -> ArtifactRecord:
31
+ path.parent.mkdir(parents=True, exist_ok=True)
32
+ path.write_text(
33
+ "#!/usr/bin/env bash\nset -euo pipefail\nexec "
34
+ + " ".join(shlex.quote(x) for x in command)
35
+ + "\n"
36
+ )
37
+ path.chmod(0o755)
38
+ return ArtifactRecord(kind="script", path=str(path))
39
+
40
+
41
+ def _render_windows_runner(
42
+ ps1_path: Path, cmd_path: Path, command: list[str]
43
+ ) -> list[ArtifactRecord]:
44
+ ps1_path.parent.mkdir(parents=True, exist_ok=True)
45
+ escaped = " ".join(
46
+ [f'"{item}"' if (" " in item or item.endswith(".cmd")) else item for item in command]
47
+ )
48
+ ps1_path.write_text(f"$ErrorActionPreference = 'Stop'\n& {escaped}\nexit $LASTEXITCODE\n")
49
+ cmd_path.write_text(
50
+ '@echo off\r\npowershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0'
51
+ + ps1_path.name
52
+ + '" %*\r\n'
53
+ )
54
+ return [
55
+ ArtifactRecord(kind="script", path=str(ps1_path)),
56
+ ArtifactRecord(kind="script", path=str(cmd_path)),
57
+ ]
58
+
59
+
60
+ def render_runner_scripts(manifest: DeploymentManifest) -> list[ArtifactRecord]:
61
+ """Render runner/watchdog scripts for the deployment profile."""
62
+
63
+ if os.name == "nt":
64
+ records = []
65
+ records.extend(
66
+ _render_windows_runner(
67
+ windows_run_script_path(manifest.profile),
68
+ windows_run_cmd_path(manifest.profile),
69
+ _command_for_script("install", "agent", "run", "--profile", manifest.profile),
70
+ )
71
+ )
72
+ records.extend(
73
+ _render_windows_runner(
74
+ windows_ensure_script_path(manifest.profile),
75
+ windows_ensure_cmd_path(manifest.profile),
76
+ _command_for_script("install", "agent", "ensure", "--profile", manifest.profile),
77
+ )
78
+ )
79
+ return records
80
+
81
+ return [
82
+ _render_unix_runner(
83
+ unix_run_script_path(manifest.profile),
84
+ _command_for_script("install", "agent", "run", "--profile", manifest.profile),
85
+ ),
86
+ _render_unix_runner(
87
+ unix_ensure_script_path(manifest.profile),
88
+ _command_for_script("install", "agent", "ensure", "--profile", manifest.profile),
89
+ ),
90
+ ]
91
+
92
+
93
+ def _linux_service_unit(manifest: DeploymentManifest, run_script: Path) -> tuple[Path, str]:
94
+ if manifest.scope == "system":
95
+ unit_path = Path("/etc/systemd/system") / f"{manifest.service_name}.service"
96
+ else:
97
+ unit_path = (
98
+ Path.home() / ".config" / "systemd" / "user" / f"{manifest.service_name}.service"
99
+ )
100
+ content = f"""[Unit]
101
+ Description=Headroom ({manifest.profile})
102
+ After=network-online.target
103
+
104
+ [Service]
105
+ Type=simple
106
+ ExecStart={run_script}
107
+ Restart=on-failure
108
+ RestartSec=5
109
+
110
+ [Install]
111
+ WantedBy=default.target
112
+ """
113
+ return unit_path, content
114
+
115
+
116
+ def _macos_launchd_plist(
117
+ manifest: DeploymentManifest, command_path: Path, *, interval: int | None = None
118
+ ) -> tuple[Path, str]:
119
+ if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
120
+ base_dir = (
121
+ Path("/Library/LaunchDaemons")
122
+ if manifest.scope == "system"
123
+ else Path.home() / "Library" / "LaunchAgents"
124
+ )
125
+ else:
126
+ base_dir = Path.home() / "Library" / "LaunchAgents"
127
+ plist_path = base_dir / f"com.headroom.{manifest.profile}.plist"
128
+ program = str(command_path)
129
+ keys = [
130
+ '<?xml version="1.0" encoding="UTF-8"?>',
131
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
132
+ '<plist version="1.0">',
133
+ "<dict>",
134
+ " <key>Label</key>",
135
+ f" <string>com.headroom.{manifest.profile}</string>",
136
+ " <key>ProgramArguments</key>",
137
+ " <array>",
138
+ f" <string>{program}</string>",
139
+ " </array>",
140
+ " <key>RunAtLoad</key>",
141
+ " <true/>",
142
+ ]
143
+ if interval is not None:
144
+ keys.extend([" <key>StartInterval</key>", f" <integer>{interval}</integer>"])
145
+ else:
146
+ keys.extend([" <key>KeepAlive</key>", " <true/>"])
147
+ keys.extend(["</dict>", "</plist>"])
148
+ return plist_path, "\n".join(keys) + "\n"
149
+
150
+
151
+ def _linux_task_spec(manifest: DeploymentManifest, ensure_script: Path) -> tuple[Path | None, str]:
152
+ if manifest.scope == "system":
153
+ cron_path = Path("/etc/cron.d") / manifest.service_name
154
+ content = f"@reboot root {ensure_script}\n*/5 * * * * root {ensure_script}\n"
155
+ return cron_path, content
156
+
157
+ marker_start = f"# >>> headroom {manifest.profile} >>>"
158
+ marker_end = f"# <<< headroom {manifest.profile} <<<"
159
+ content = (
160
+ f"{marker_start}\n@reboot {ensure_script}\n*/5 * * * * {ensure_script}\n{marker_end}\n"
161
+ )
162
+ return None, content
163
+
164
+
165
+ def install_supervisor(manifest: DeploymentManifest) -> list[ArtifactRecord]:
166
+ """Install service/task artifacts for the deployment."""
167
+
168
+ records = render_runner_scripts(manifest)
169
+ artifact_paths = {Path(item.path).name: Path(item.path) for item in records}
170
+
171
+ if manifest.supervisor_kind == SupervisorKind.NONE.value:
172
+ return records
173
+
174
+ if (
175
+ sys.platform.startswith("linux")
176
+ and manifest.supervisor_kind == SupervisorKind.SERVICE.value
177
+ ):
178
+ unit_path, content = _linux_service_unit(manifest, artifact_paths["run-headroom.sh"])
179
+ unit_path.parent.mkdir(parents=True, exist_ok=True)
180
+ unit_path.write_text(content)
181
+ flags = [] if manifest.scope == "system" else ["--user"]
182
+ subprocess.run(["systemctl", *flags, "daemon-reload"], check=True)
183
+ subprocess.run(["systemctl", *flags, "enable", manifest.service_name], check=True)
184
+ records.append(ArtifactRecord(kind="service-unit", path=str(unit_path)))
185
+ return records
186
+
187
+ if sys.platform.startswith("linux") and manifest.supervisor_kind == SupervisorKind.TASK.value:
188
+ cron_path, content = _linux_task_spec(manifest, artifact_paths["ensure-headroom.sh"])
189
+ if cron_path is not None:
190
+ cron_path.parent.mkdir(parents=True, exist_ok=True)
191
+ cron_path.write_text(content)
192
+ records.append(ArtifactRecord(kind="cron", path=str(cron_path)))
193
+ else:
194
+ current = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
195
+ existing = current.stdout if current.returncode == 0 else ""
196
+ marker_start = f"# >>> headroom {manifest.profile} >>>"
197
+ marker_end = f"# <<< headroom {manifest.profile} <<<"
198
+ pattern = re.compile(
199
+ re.escape(marker_start) + r".*?" + re.escape(marker_end), re.DOTALL
200
+ )
201
+ merged = pattern.sub("", existing).strip()
202
+ new_content = (merged + "\n\n" + content).strip() + "\n"
203
+ subprocess.run(["crontab", "-"], input=new_content, text=True, check=True)
204
+ records.append(ArtifactRecord(kind="crontab", path=f"user:{manifest.profile}"))
205
+ return records
206
+
207
+ if sys.platform == "darwin":
208
+ if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
209
+ plist_path, content = _macos_launchd_plist(manifest, artifact_paths["run-headroom.sh"])
210
+ else:
211
+ plist_path, content = _macos_launchd_plist(
212
+ manifest, artifact_paths["ensure-headroom.sh"], interval=300
213
+ )
214
+ plist_path.parent.mkdir(parents=True, exist_ok=True)
215
+ plist_path.write_text(content)
216
+ domain = (
217
+ f"system/{plist_path.stem}"
218
+ if manifest.scope == "system"
219
+ and manifest.supervisor_kind == SupervisorKind.SERVICE.value
220
+ else f"gui/{os.getuid()}/{plist_path.stem}"
221
+ )
222
+ subprocess.run(["launchctl", "bootout", domain], capture_output=True, text=True)
223
+ bootstrap_domain = (
224
+ "system"
225
+ if manifest.scope == "system"
226
+ and manifest.supervisor_kind == SupervisorKind.SERVICE.value
227
+ else f"gui/{os.getuid()}"
228
+ )
229
+ subprocess.run(["launchctl", "bootstrap", bootstrap_domain, str(plist_path)], check=True)
230
+ records.append(ArtifactRecord(kind="plist", path=str(plist_path)))
231
+ return records
232
+
233
+ if os.name == "nt" and manifest.supervisor_kind == SupervisorKind.SERVICE.value:
234
+ service_bin = f'cmd.exe /c "{windows_run_cmd_path(manifest.profile)}"'
235
+ subprocess.run(
236
+ ["sc.exe", "create", manifest.service_name, f"binPath= {service_bin}", "start= auto"],
237
+ check=True,
238
+ )
239
+ subprocess.run(
240
+ ["sc.exe", "failure", manifest.service_name, "reset= 0", "actions= restart/5000"],
241
+ check=True,
242
+ )
243
+ records.append(ArtifactRecord(kind="windows-service", path=manifest.service_name))
244
+ return records
245
+
246
+ if os.name == "nt" and manifest.supervisor_kind == SupervisorKind.TASK.value:
247
+ startup_name = f"{manifest.service_name}-startup"
248
+ health_name = f"{manifest.service_name}-health"
249
+ startup_cmd = str(windows_ensure_cmd_path(manifest.profile))
250
+ user_args = ["/RU", "SYSTEM"] if manifest.scope == "system" else []
251
+ start_schedule = [
252
+ "schtasks",
253
+ "/Create",
254
+ "/TN",
255
+ startup_name,
256
+ "/TR",
257
+ startup_cmd,
258
+ "/SC",
259
+ "ONSTART",
260
+ "/F",
261
+ *user_args,
262
+ ]
263
+ health_schedule = [
264
+ "schtasks",
265
+ "/Create",
266
+ "/TN",
267
+ health_name,
268
+ "/TR",
269
+ startup_cmd,
270
+ "/SC",
271
+ "MINUTE",
272
+ "/MO",
273
+ "5",
274
+ "/F",
275
+ *user_args,
276
+ ]
277
+ subprocess.run(start_schedule, check=True)
278
+ subprocess.run(health_schedule, check=True)
279
+ records.extend(
280
+ [
281
+ ArtifactRecord(kind="windows-task", path=startup_name),
282
+ ArtifactRecord(kind="windows-task", path=health_name),
283
+ ]
284
+ )
285
+ return records
286
+
287
+ raise click.ClickException(
288
+ f"Persistent {manifest.supervisor_kind} mode is not supported on this platform."
289
+ )
290
+
291
+
292
+ def start_supervisor(manifest: DeploymentManifest) -> None:
293
+ """Start the installed supervisor or runtime for a deployment."""
294
+
295
+ if manifest.supervisor_kind == SupervisorKind.NONE.value:
296
+ return
297
+ if sys.platform.startswith("linux"):
298
+ flags = [] if manifest.scope == "system" else ["--user"]
299
+ subprocess.run(["systemctl", *flags, "restart", manifest.service_name], check=True)
300
+ return
301
+ if sys.platform == "darwin":
302
+ label = f"com.headroom.{manifest.profile}"
303
+ domain = (
304
+ "system"
305
+ if manifest.scope == "system"
306
+ and manifest.supervisor_kind == SupervisorKind.SERVICE.value
307
+ else f"gui/{os.getuid()}"
308
+ )
309
+ subprocess.run(["launchctl", "kickstart", "-k", f"{domain}/{label}"], check=True)
310
+ return
311
+ if os.name == "nt" and manifest.supervisor_kind == SupervisorKind.SERVICE.value:
312
+ subprocess.run(["sc.exe", "start", manifest.service_name], check=True)
313
+
314
+
315
+ def stop_supervisor(manifest: DeploymentManifest) -> None:
316
+ """Stop the installed supervisor for a deployment."""
317
+
318
+ if manifest.supervisor_kind == SupervisorKind.NONE.value:
319
+ return
320
+ if sys.platform.startswith("linux"):
321
+ flags = [] if manifest.scope == "system" else ["--user"]
322
+ subprocess.run(["systemctl", *flags, "stop", manifest.service_name], check=True)
323
+ return
324
+ if sys.platform == "darwin":
325
+ label = f"com.headroom.{manifest.profile}"
326
+ domain = (
327
+ "system"
328
+ if manifest.scope == "system"
329
+ and manifest.supervisor_kind == SupervisorKind.SERVICE.value
330
+ else f"gui/{os.getuid()}"
331
+ )
332
+ subprocess.run(["launchctl", "bootout", f"{domain}/{label}"], check=True)
333
+ return
334
+ if os.name == "nt" and manifest.supervisor_kind == SupervisorKind.SERVICE.value:
335
+ subprocess.run(["sc.exe", "stop", manifest.service_name], check=True)
336
+
337
+
338
+ def remove_supervisor(manifest: DeploymentManifest) -> None:
339
+ """Remove installed service/task artifacts."""
340
+
341
+ if manifest.supervisor_kind == SupervisorKind.NONE.value:
342
+ return
343
+
344
+ if sys.platform.startswith("linux"):
345
+ if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
346
+ flags = [] if manifest.scope == "system" else ["--user"]
347
+ subprocess.run(
348
+ ["systemctl", *flags, "disable", "--now", manifest.service_name],
349
+ capture_output=True,
350
+ text=True,
351
+ )
352
+ unit_path, _ = _linux_service_unit(manifest, unix_run_script_path(manifest.profile))
353
+ if unit_path.exists():
354
+ unit_path.unlink()
355
+ subprocess.run(["systemctl", *flags, "daemon-reload"], capture_output=True, text=True)
356
+ return
357
+ cron_path, _ = _linux_task_spec(manifest, unix_ensure_script_path(manifest.profile))
358
+ if cron_path and cron_path.exists():
359
+ cron_path.unlink()
360
+ return
361
+ current = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
362
+ if current.returncode != 0:
363
+ return
364
+ marker_start = f"# >>> headroom {manifest.profile} >>>"
365
+ marker_end = f"# <<< headroom {manifest.profile} <<<"
366
+ pattern = re.compile(re.escape(marker_start) + r".*?" + re.escape(marker_end), re.DOTALL)
367
+ content = pattern.sub("", current.stdout).strip()
368
+ subprocess.run(
369
+ ["crontab", "-"], input=(content + "\n") if content else "", text=True, check=True
370
+ )
371
+ return
372
+
373
+ if sys.platform == "darwin":
374
+ plist_path, _ = _macos_launchd_plist(
375
+ manifest,
376
+ unix_run_script_path(manifest.profile)
377
+ if manifest.supervisor_kind == SupervisorKind.SERVICE.value
378
+ else unix_ensure_script_path(manifest.profile),
379
+ interval=300 if manifest.supervisor_kind == SupervisorKind.TASK.value else None,
380
+ )
381
+ label = f"com.headroom.{manifest.profile}"
382
+ domain = (
383
+ "system"
384
+ if manifest.scope == "system"
385
+ and manifest.supervisor_kind == SupervisorKind.SERVICE.value
386
+ else f"gui/{os.getuid()}"
387
+ )
388
+ subprocess.run(
389
+ ["launchctl", "bootout", f"{domain}/{label}"], capture_output=True, text=True
390
+ )
391
+ if plist_path.exists():
392
+ plist_path.unlink()
393
+ return
394
+
395
+ if os.name == "nt":
396
+ if manifest.supervisor_kind == SupervisorKind.SERVICE.value:
397
+ subprocess.run(
398
+ ["sc.exe", "stop", manifest.service_name], capture_output=True, text=True
399
+ )
400
+ subprocess.run(
401
+ ["sc.exe", "delete", manifest.service_name], capture_output=True, text=True
402
+ )
403
+ return
404
+ subprocess.run(
405
+ ["schtasks", "/Delete", "/TN", f"{manifest.service_name}-startup", "/F"],
406
+ capture_output=True,
407
+ text=True,
408
+ )
409
+ subprocess.run(
410
+ ["schtasks", "/Delete", "/TN", f"{manifest.service_name}-health", "/F"],
411
+ capture_output=True,
412
+ text=True,
413
+ )
headroom/proxy/server.py CHANGED
@@ -1093,6 +1093,15 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
1093
  "uptime_seconds": _uptime_seconds(),
1094
  "checks": checks,
1095
  }
 
 
 
 
 
 
 
 
 
1096
  if include_config:
1097
  payload["config"] = {
1098
  "backend": config.backend,
 
1093
  "uptime_seconds": _uptime_seconds(),
1094
  "checks": checks,
1095
  }
1096
+ deployment_profile = os.environ.get("HEADROOM_DEPLOYMENT_PROFILE")
1097
+ if deployment_profile:
1098
+ payload["deployment"] = {
1099
+ "profile": deployment_profile,
1100
+ "preset": os.environ.get("HEADROOM_DEPLOYMENT_PRESET"),
1101
+ "runtime": os.environ.get("HEADROOM_DEPLOYMENT_RUNTIME"),
1102
+ "supervisor": os.environ.get("HEADROOM_DEPLOYMENT_SUPERVISOR"),
1103
+ "scope": os.environ.get("HEADROOM_DEPLOYMENT_SCOPE"),
1104
+ }
1105
  if include_config:
1106
  payload["config"] = {
1107
  "backend": config.backend,
mkdocs.yml CHANGED
@@ -87,6 +87,7 @@ nav:
87
  - Quickstart: quickstart.md
88
  - Installation: getting-started.md
89
  - Docker-Native Install: docker-install.md
 
90
  - Configuration: configuration.md
91
  - User Guide:
92
  - Proxy Server: proxy.md
 
87
  - Quickstart: quickstart.md
88
  - Installation: getting-started.md
89
  - Docker-Native Install: docker-install.md
90
+ - Persistent Installs: persistent-installs.md
91
  - Configuration: configuration.md
92
  - User Guide:
93
  - Proxy Server: proxy.md
tests/test_cli/test_install_cli.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from click.testing import CliRunner
4
+
5
+ from headroom.cli.main import main
6
+
7
+
8
+ def test_install_apply_starts_service_supervisor(monkeypatch) -> None:
9
+ runner = CliRunner()
10
+ calls: list[str] = []
11
+
12
+ class Manifest:
13
+ profile = "default"
14
+ preset = "persistent-service"
15
+ runtime_kind = "python"
16
+ supervisor_kind = "service"
17
+ scope = "user"
18
+ health_url = "http://127.0.0.1:8787/readyz"
19
+ targets = ["claude", "codex"]
20
+ mutations = []
21
+ artifacts = []
22
+
23
+ manifest = Manifest()
24
+
25
+ monkeypatch.setattr("headroom.cli.install.build_manifest", lambda **_: manifest)
26
+ monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: None)
27
+ monkeypatch.setattr("headroom.cli.install.apply_mutations", lambda deployment: [])
28
+ monkeypatch.setattr("headroom.cli.install.install_supervisor", lambda deployment: [])
29
+ monkeypatch.setattr(
30
+ "headroom.cli.install.save_manifest", lambda deployment: calls.append("save")
31
+ )
32
+ monkeypatch.setattr(
33
+ "headroom.cli.install.start_supervisor", lambda deployment: calls.append("start_service")
34
+ )
35
+ monkeypatch.setattr(
36
+ "headroom.cli.install.start_detached_agent", lambda profile: calls.append("start_agent")
37
+ )
38
+ monkeypatch.setattr(
39
+ "headroom.cli.install.start_persistent_docker",
40
+ lambda deployment: calls.append("start_docker"),
41
+ )
42
+ monkeypatch.setattr(
43
+ "headroom.cli.install.wait_ready", lambda deployment, timeout_seconds=45: True
44
+ )
45
+
46
+ result = runner.invoke(main, ["install", "apply"])
47
+
48
+ assert result.exit_code == 0, result.output
49
+ assert "Installed persistent deployment 'default'" in result.output
50
+ assert "Targets: claude, codex" in result.output
51
+ assert calls == ["save", "start_service"]
52
+
53
+
54
+ def test_install_status_includes_backend_from_health_probe(monkeypatch) -> None:
55
+ runner = CliRunner()
56
+
57
+ class Manifest:
58
+ profile = "default"
59
+ preset = "persistent-service"
60
+ runtime_kind = "python"
61
+ supervisor_kind = "service"
62
+ scope = "user"
63
+ port = 8787
64
+ backend = "anthropic"
65
+ health_url = "http://127.0.0.1:8787/readyz"
66
+
67
+ monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
68
+ monkeypatch.setattr("headroom.cli.install.runtime_status", lambda manifest: "running")
69
+ monkeypatch.setattr("headroom.cli.install.probe_ready", lambda url: True)
70
+ monkeypatch.setattr(
71
+ "headroom.cli.install.probe_json",
72
+ lambda url: {"config": {"backend": "anthropic"}},
73
+ )
74
+
75
+ result = runner.invoke(main, ["install", "status"])
76
+
77
+ assert result.exit_code == 0, result.output
78
+ assert "Status: running" in result.output
79
+ assert "Healthy: yes" in result.output
80
+ assert "Backend: anthropic" in result.output
81
+
82
+
83
+ def test_install_restart_uses_internal_helpers(monkeypatch) -> None:
84
+ runner = CliRunner()
85
+ calls: list[str] = []
86
+
87
+ class Manifest:
88
+ profile = "default"
89
+ preset = "persistent-service"
90
+ runtime_kind = "python"
91
+ supervisor_kind = "service"
92
+ scope = "user"
93
+ health_url = "http://127.0.0.1:8787/readyz"
94
+
95
+ monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: Manifest())
96
+ monkeypatch.setattr(
97
+ "headroom.cli.install.stop_supervisor", lambda manifest: calls.append("stop_supervisor")
98
+ )
99
+ monkeypatch.setattr(
100
+ "headroom.cli.install.stop_runtime", lambda manifest: calls.append("stop_runtime")
101
+ )
102
+ monkeypatch.setattr(
103
+ "headroom.cli.install.start_supervisor", lambda manifest: calls.append("start_supervisor")
104
+ )
105
+ monkeypatch.setattr(
106
+ "headroom.cli.install.wait_ready", lambda manifest, timeout_seconds=45: True
107
+ )
108
+
109
+ result = runner.invoke(main, ["install", "restart"])
110
+
111
+ assert result.exit_code == 0, result.output
112
+ assert "Restarted deployment 'default'." in result.output
113
+ assert calls == ["stop_supervisor", "stop_runtime", "start_supervisor"]
tests/test_cli/test_wrap_persistent.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from headroom.cli.wrap import _ensure_proxy
4
+
5
+
6
+ class _Manifest:
7
+ profile = "default"
8
+ preset = "persistent-service"
9
+ supervisor_kind = "service"
10
+ health_url = "http://127.0.0.1:8787/readyz"
11
+
12
+
13
+ def test_ensure_proxy_recovers_matching_persistent_deployment(monkeypatch) -> None:
14
+ calls: list[str] = []
15
+
16
+ monkeypatch.setattr("headroom.cli.wrap._check_proxy", lambda port: False)
17
+ monkeypatch.setattr("headroom.cli.wrap._find_persistent_manifest", lambda port: _Manifest())
18
+ monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
19
+ monkeypatch.setattr(
20
+ "headroom.install.supervisors.start_supervisor",
21
+ lambda manifest: calls.append(f"start:{manifest.profile}"),
22
+ )
23
+ monkeypatch.setattr(
24
+ "headroom.install.runtime.wait_ready", lambda manifest, timeout_seconds=45: True
25
+ )
26
+ monkeypatch.setattr(
27
+ "headroom.cli.wrap._start_proxy",
28
+ lambda *args, **kwargs: (_ for _ in ()).throw(
29
+ AssertionError("ephemeral proxy should not start")
30
+ ),
31
+ )
32
+
33
+ result = _ensure_proxy(8787, False)
34
+
35
+ assert result is None
36
+ assert calls == ["start:default"]
37
+
38
+
39
+ def test_ensure_proxy_recovers_persistent_deployment_when_socket_is_bound(monkeypatch) -> None:
40
+ calls: list[str] = []
41
+
42
+ monkeypatch.setattr("headroom.cli.wrap._check_proxy", lambda port: True)
43
+ monkeypatch.setattr("headroom.cli.wrap._find_persistent_manifest", lambda port: _Manifest())
44
+ monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False)
45
+ monkeypatch.setattr(
46
+ "headroom.install.supervisors.start_supervisor",
47
+ lambda manifest: calls.append(f"start:{manifest.profile}"),
48
+ )
49
+ monkeypatch.setattr(
50
+ "headroom.install.runtime.wait_ready", lambda manifest, timeout_seconds=45: True
51
+ )
52
+
53
+ result = _ensure_proxy(8787, False)
54
+
55
+ assert result is None
56
+ assert calls == ["start:default"]
tests/test_install/test_planner.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from headroom.install.models import InstallPreset, ProviderSelectionMode, ToolTarget
4
+ from headroom.install.planner import build_manifest, resolve_targets
5
+
6
+
7
+ def test_resolve_targets_auto_falls_back_when_detection_empty(monkeypatch) -> None:
8
+ monkeypatch.setattr("headroom.install.planner.detect_targets", lambda: [])
9
+
10
+ targets = resolve_targets(ProviderSelectionMode.AUTO.value, [])
11
+
12
+ assert targets == [
13
+ ToolTarget.CLAUDE.value,
14
+ ToolTarget.CODEX.value,
15
+ ToolTarget.COPILOT.value,
16
+ ]
17
+
18
+
19
+ def test_build_manifest_for_persistent_docker_sets_expected_defaults() -> None:
20
+ manifest = build_manifest(
21
+ profile="default",
22
+ preset=InstallPreset.PERSISTENT_DOCKER.value,
23
+ runtime_kind="docker",
24
+ scope="user",
25
+ provider_mode="manual",
26
+ targets=["claude", "copilot"],
27
+ port=8787,
28
+ backend="anthropic",
29
+ anyllm_provider=None,
30
+ region=None,
31
+ proxy_mode="token",
32
+ memory_enabled=True,
33
+ telemetry_enabled=False,
34
+ image="ghcr.io/chopratejas/headroom:latest",
35
+ )
36
+
37
+ assert manifest.supervisor_kind == "none"
38
+ assert manifest.runtime_kind == "docker"
39
+ assert manifest.health_url == "http://127.0.0.1:8787/readyz"
40
+ assert manifest.base_env["HEADROOM_PORT"] == "8787"
41
+ assert manifest.base_env["HEADROOM_TELEMETRY"] == "off"
42
+ assert manifest.tool_envs["claude"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
43
+ assert manifest.tool_envs["copilot"]["COPILOT_PROVIDER_TYPE"] == "anthropic"
44
+ assert "--memory" in manifest.proxy_args
tests/test_install/test_providers.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from headroom.install.models import DeploymentManifest
7
+ from headroom.install.providers import (
8
+ _apply_claude_provider_scope,
9
+ _apply_codex_provider_scope,
10
+ _revert_claude_provider_scope,
11
+ _revert_codex_provider_scope,
12
+ )
13
+
14
+
15
+ def _manifest(tmp_path: Path) -> DeploymentManifest:
16
+ return DeploymentManifest(
17
+ profile="default",
18
+ preset="persistent-service",
19
+ runtime_kind="python",
20
+ supervisor_kind="service",
21
+ scope="provider",
22
+ provider_mode="manual",
23
+ targets=["claude", "codex"],
24
+ port=8787,
25
+ host="127.0.0.1",
26
+ backend="anthropic",
27
+ memory_db_path=str(tmp_path / "memory.db"),
28
+ tool_envs={
29
+ "claude": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"},
30
+ "codex": {"OPENAI_BASE_URL": "http://127.0.0.1:8787/v1"},
31
+ },
32
+ )
33
+
34
+
35
+ def test_apply_and_revert_claude_provider_scope(monkeypatch, tmp_path: Path) -> None:
36
+ settings_path = tmp_path / "settings.json"
37
+ settings_path.write_text(
38
+ json.dumps({"env": {"ANTHROPIC_API_KEY": "keep", "ANTHROPIC_BASE_URL": "https://old"}})
39
+ )
40
+ monkeypatch.setattr("headroom.install.providers.claude_settings_path", lambda: settings_path)
41
+ manifest = _manifest(tmp_path)
42
+
43
+ mutation = _apply_claude_provider_scope(manifest)
44
+ payload = json.loads(settings_path.read_text())
45
+ assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
46
+ assert payload["env"]["ANTHROPIC_API_KEY"] == "keep"
47
+
48
+ _revert_claude_provider_scope(mutation, manifest.tool_envs["claude"])
49
+ reverted = json.loads(settings_path.read_text())
50
+ assert reverted["env"]["ANTHROPIC_BASE_URL"] == "https://old"
51
+ assert reverted["env"]["ANTHROPIC_API_KEY"] == "keep"
52
+
53
+
54
+ def test_apply_and_revert_codex_provider_scope(monkeypatch, tmp_path: Path) -> None:
55
+ config_path = tmp_path / "config.toml"
56
+ config_path.write_text('model = "gpt-4o"\n')
57
+ monkeypatch.setattr("headroom.install.providers.codex_config_path", lambda: config_path)
58
+ manifest = _manifest(tmp_path)
59
+
60
+ mutation = _apply_codex_provider_scope(manifest)
61
+ content = config_path.read_text()
62
+ assert 'model_provider = "headroom"' in content
63
+ assert 'base_url = "http://127.0.0.1:8787/v1"' in content
64
+
65
+ _revert_codex_provider_scope(mutation)
66
+ reverted = config_path.read_text()
67
+ assert 'model_provider = "headroom"' not in reverted
68
+ assert reverted.strip() == 'model = "gpt-4o"'
69
+
70
+
71
+ def test_apply_openclaw_provider_scope_uses_manifest_port(monkeypatch, tmp_path: Path) -> None:
72
+ recorded: list[list[str]] = []
73
+ monkeypatch.setattr("headroom.install.providers.shutil_which", lambda name: "openclaw")
74
+ monkeypatch.setattr(
75
+ "headroom.install.providers.resolve_headroom_command",
76
+ lambda: ["headroom"],
77
+ )
78
+ monkeypatch.setattr(
79
+ "headroom.install.providers._invoke_openclaw",
80
+ lambda command: recorded.append(command),
81
+ )
82
+ monkeypatch.setattr(
83
+ "headroom.install.providers.openclaw_config_path",
84
+ lambda: tmp_path / "openclaw.json",
85
+ )
86
+ manifest = _manifest(tmp_path)
87
+ manifest.port = 9999
88
+
89
+ from headroom.install.providers import _apply_openclaw_provider_scope
90
+
91
+ _apply_openclaw_provider_scope(manifest)
92
+
93
+ assert recorded == [["headroom", "wrap", "openclaw", "--no-auto-start", "--proxy-port", "9999"]]
tests/test_install/test_runtime.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from headroom.install.models import DeploymentManifest
6
+ from headroom.install.runtime import build_runtime_command
7
+
8
+
9
+ def test_build_runtime_command_for_docker_includes_deployment_env(
10
+ monkeypatch, tmp_path: Path
11
+ ) -> None:
12
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
13
+ manifest = DeploymentManifest(
14
+ profile="default",
15
+ preset="persistent-docker",
16
+ runtime_kind="docker",
17
+ supervisor_kind="none",
18
+ scope="user",
19
+ provider_mode="manual",
20
+ targets=["claude"],
21
+ port=8787,
22
+ host="127.0.0.1",
23
+ backend="anthropic",
24
+ image="ghcr.io/chopratejas/headroom:latest",
25
+ base_env={"HEADROOM_PORT": "8787"},
26
+ proxy_args=["--host", "127.0.0.1", "--port", "8787"],
27
+ )
28
+
29
+ command = build_runtime_command(manifest)
30
+
31
+ joined = " ".join(command)
32
+ assert command[:3] == ["docker", "run", "--rm"]
33
+ assert "HEADROOM_DEPLOYMENT_PROFILE=default" in joined
34
+ assert "HEADROOM_DEPLOYMENT_PRESET=persistent-docker" in joined
35
+ assert "127.0.0.1:8787:8787" in joined
36
+ assert "ghcr.io/chopratejas/headroom:latest" in command
tests/test_proxy_healthchecks.py CHANGED
@@ -64,6 +64,34 @@ def test_health_preserves_backwards_compatible_config_payload(client):
64
  }
65
 
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  def test_health_remains_200_when_proxy_is_not_ready(client):
68
  client.app.state.ready = False
69
 
 
64
  }
65
 
66
 
67
+ def test_health_includes_deployment_metadata_when_present(monkeypatch):
68
+ monkeypatch.setenv("HEADROOM_DEPLOYMENT_PROFILE", "default")
69
+ monkeypatch.setenv("HEADROOM_DEPLOYMENT_PRESET", "persistent-service")
70
+ monkeypatch.setenv("HEADROOM_DEPLOYMENT_RUNTIME", "python")
71
+ monkeypatch.setenv("HEADROOM_DEPLOYMENT_SUPERVISOR", "service")
72
+ monkeypatch.setenv("HEADROOM_DEPLOYMENT_SCOPE", "user")
73
+
74
+ config = ProxyConfig(
75
+ optimize=False,
76
+ cache_enabled=False,
77
+ rate_limit_enabled=False,
78
+ cost_tracking_enabled=False,
79
+ )
80
+ app = create_app(config)
81
+
82
+ with TestClient(app) as client:
83
+ response = client.get("/health")
84
+
85
+ assert response.status_code == 200
86
+ assert response.json()["deployment"] == {
87
+ "profile": "default",
88
+ "preset": "persistent-service",
89
+ "runtime": "python",
90
+ "supervisor": "service",
91
+ "scope": "user",
92
+ }
93
+
94
+
95
  def test_health_remains_200_when_proxy_is_not_ready(client):
96
  client.app.state.ready = False
97