# sol-web Harness Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use /ship (recommended) or /executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ship `sw`, a wrapper-contract CLI that opens Claude Code with its brain served by the owner's free ChatGPT web session, so the real agentic tool loop runs on chat quota.

**Architecture:** A local OpenAI-compatible daemon (`solwebd`) fronts one live Marionette ChatGPT conversation. ccr routes Claude Code at it as an ordinary provider. `translate.py` reconciles the client's stateless `messages[]` against the browser's stateful conversation; `protocol.py` synthesises the tool-call contract and parses replies. Only `solwebd` and `sw.sh` are new — Claude Code, ccr and the `gptbridge` browser driver are reused unchanged.

**Tech Stack:** Python 3 (stdlib `http.server` + existing `browser.py`/`chat.py` Marionette driver), bash (wrapper contract), pytest, ccr (claude-code-router), Claude Code.

**Spec:** `docs/specs/2026-08-08-sol-web-harness-design.md` — canonical. Read it before Task 1.

**Base branch:** `wt/gptarm`. `origin/main` does NOT contain `modules/gptbridge/browser.py`, `chat.py` or `ask_gpt.py` — that work is unlanded. Build on and land into `wt/gptarm`, never `main`.

**Install before landing (owner standing rule, `CLAUDE.md`):** this is local infra the owner runs. After Task 9, install live and verify the real installed entrypoint (Task 11) BEFORE any landing ceremony.

---

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 1 | Task 1, Task 2, Task 3, Task 4 | `modules/gptbridge/protocol.py`+test, `modules/gptbridge/translate.py`+test, `modules/gptbridge/tests/ccr_passthrough_probe.py`, `modules/gptbridge/chat.py` | ✅ no overlap |
| 2 | Task 5 | `modules/gptbridge/solwebd.py`, `modules/gptbridge/bin/solwebd` | single task |
| 3 | Task 6, Task 7 | `modules/gptbridge/tests/test_solwebd_loop.py`+fake, `modules/harness/wrappers/sw.sh` | ✅ no overlap |
| 4 | Task 8, Task 9 | `modules/harness/wrappers/sw-exitcodes.test.sh`, `modules/harness/presets/adapters.json` + ccr config | ✅ no overlap |
| 5 | Task 10 | `modules/gptbridge/ask_gpt.py` | single task |
| 6 | Task 11 | none (live verification + install) | single task |

Task 4 modifies `chat.py`; no other wave-1 task touches it. Task 3 is a throwaway probe under `tests/` that no other task reads.

---

## File Structure

| File | Responsibility |
|------|----------------|
| `modules/gptbridge/protocol.py` | **Create.** Render the tool-call contract + tool schemas into a preamble; parse a reply's code blocks into `ToolCall` / `Final` / `Malformed`. Knows the wire protocol, nothing about HTTP or browsers. |
| `modules/gptbridge/translate.py` | **Create.** Reconcile stateless `messages[]` against a stateful browser conversation: session keying, the conversation Registry, prefix-diff delivery, turn-envelope rendering. |
| `modules/gptbridge/solwebd.py` | **Create.** The HTTP daemon: owns one browser session and `profile.lock`, serves `/v1/chat/completions` (+ models/healthz/shutdown) from one FIFO queue, maps failures to status codes. |
| `modules/gptbridge/bin/solwebd` | **Create.** venv-python launcher, mirroring `bin/ask-gpt`. |
| `modules/gptbridge/chat.py` | **Modify.** Add usage-cap detection. Everything else unchanged. |
| `modules/gptbridge/ask_gpt.py` | **Modify.** Probe `/healthz` and route through the daemon when it is up; otherwise today's direct path unchanged. |
| `modules/harness/wrappers/sw.sh` | **Create.** The wrapper-contract CLI: ensure daemon → ensure ccr → run Claude Code foreground → status JSON + exit code. |
| `modules/harness/wrappers/sw-exitcodes.test.sh` | **Create.** Contract test: every exit code reachable with the daemon stubbed. |
| `modules/harness/presets/adapters.json` | **Modify.** One `solweb` adapter entry. |

Tests live in `modules/gptbridge/tests/` (pytest, run via `modules/gptbridge/python3 -m pytest`).

---

### Task 1: `protocol.py` — wire protocol in, tool calls out

**Wave:** 1
**Blocks:** Task 5
**Blocked by:** —

**Files:**
- Create: `modules/gptbridge/protocol.py` — prompt synthesis + reply parsing
- Test: `modules/gptbridge/tests/test_protocol.py`

**Contract (pin EXACTLY):**
- `render_preamble(system: str, tools: list[dict]) -> str` — the protocol contract, then each tool's name/description/JSON Schema, then the client's system prompt.
- `parse_reply(blocks: list[str]) -> ReplyKind`
- `ReplyKind = ToolCall(name: str, arguments: dict) | Final(text: str) | Malformed(reason: str)` — three dataclasses or a tagged union; callers match on type.
- The preamble MUST instruct the model to emit exactly one fenced json block and nothing else, in one of these two shapes, verbatim:

```json
{"tool": "<tool name>", "arguments": {}}
```

```json
{"tool": "final", "text": "<answer>"}
```

- `correction_prompt(reason: str) -> str` — the single correction turn's text, naming the exact violation.

**Behavior:**
- Exactly one block that parses as JSON with a known `tool` → `ToolCall` (or `Final` when `tool == "final"`, taking `text`).
- Zero blocks → `Final(prose)`; the caller passes the reply's plain text.
- Two or more blocks → `Malformed("expected exactly one json block, got N")`.
- Invalid JSON → `Malformed` naming the parse error.
- `tool` naming no tool in the schema list → `Malformed("unknown tool <name>")`.
- `arguments` absent or not an object on a non-final call → `Malformed`.
- `parse_reply` MUST NOT look for fence markers. ChatGPT renders fences as DOM nodes so they are absent from `innerText`; blocks arrive already extracted from `pre code` elements.

**Acceptance (one executable check):**
- Run: `modules/gptbridge/python3 -m pytest modules/gptbridge/tests/test_protocol.py -q`
- Expected: PASS — covering one-block→ToolCall, `final`→Final, zero→Final, two→Malformed, invalid JSON→Malformed, unknown tool→Malformed.

- [ ] Write tests covering the behavior above
- [ ] Implement to satisfy the contract + acceptance
- [ ] Run the acceptance check
- [ ] Commit: `git add modules/gptbridge/protocol.py modules/gptbridge/tests/test_protocol.py && git commit -m "feat: sol-web wire protocol synthesis and reply parsing"`

---

### Task 2: `translate.py` — stateless `messages[]` ⇄ stateful conversation

**Wave:** 1
**Blocks:** Task 5
**Blocked by:** —

**Files:**
- Create: `modules/gptbridge/translate.py` — session keying, Registry, prefix-diff delivery, envelope rendering
- Test: `modules/gptbridge/tests/test_translate.py`

**Contract (pin EXACTLY):**
```
session_key(messages: list[dict], tools: list[dict], effort: str) -> str
    sha256 over the system message text + the first user message text
    + sorted tool names + the effort string; hex, first 16 chars.

class Conversation
    url: str; delivered: list[dict]; turns: int; opened_at: float; effort: str

class Registry
    get(key: str) -> Conversation | None
    open(key: str, preamble: str, effort: str) -> Conversation
    # open() navigates to a NEW chat and registers it; the SEND path belongs to
    # the daemon, which re-asserts set_effort before every send.

deliver(conv: Conversation, messages: list[dict]) -> str
    Returns ONLY the text to type this turn. Raises ReplayNeeded on mismatch.

class ReplayNeeded(Exception)

MAX_RESULT_CHARS = 60000
```

**Turn envelope — the exact text typed into the composer for a delta, verbatim:**
```
TOOL RESULT [<tool_call_id>]
<content>

TOOL RESULT [<tool_call_id>]
<content>

USER
<content>
```

**Behavior:**
- `deliver`: `conv.delivered` is the message prefix already sent. `messages` starts with that exact prefix → render and return only the suffix, and extend `conv.delivered`. Otherwise raise `ReplayNeeded` — a desynced conversation is replaced, never patched.
- Envelope: every `role:"tool"` message becomes a `TOOL RESULT [<tool_call_id>]` block in order; a trailing `role:"user"` message becomes the `USER` block; blocks separated by one blank line; no trailing blank line.
- A tool result whose content exceeds `MAX_RESULT_CHARS` is elided in the MIDDLE with `\n…<N> chars elided…\n`, keeping equal head and tail, where `<N>` is the number of characters removed.
- `session_key` is stable across a client session and differs whenever system text, first user text, the tool-name set, OR the effort changes.

**Acceptance (one executable check):**
- Run: `modules/gptbridge/python3 -m pytest modules/gptbridge/tests/test_translate.py -q`
- Expected: PASS — key stability incl. effort sensitivity; matching prefix → suffix only; rewritten history → `ReplayNeeded`; multi-result + user turn renders the pinned envelope; oversized result middle-elided.

- [ ] Write tests covering the behavior above
- [ ] Implement to satisfy the contract + acceptance
- [ ] Run the acceptance check
- [ ] Commit: `git add modules/gptbridge/translate.py modules/gptbridge/tests/test_translate.py && git commit -m "feat: bridge stateless chat-completions history to a live conversation"`

---

### Task 3: Prove ccr passes tools through to a bare provider

**Wave:** 1
**Blocks:** Task 9
**Blocked by:** —
**Gated:** `g1` (architecture) — resolve before Task 9 registers the real provider.

This is the ONE layer the design owns neither side of. Find a failure with a stub, not with the browser.

**Files:**
- Create: `modules/gptbridge/tests/ccr_passthrough_probe.py` — a stub OpenAI-compatible server on `127.0.0.1:8791` that records the request it receives and replies with a synthesized `tool_calls` response

**Contract:**
- Stub serves `POST /v1/chat/completions` and `GET /v1/models` (the six `sol-web-*` ids), requires `Authorization: Bearer <token>`, and writes each received request body to `~/.overdeck/gptbridge/tmp/ccr-probe-<n>.json`.
- Stub's reply: `finish_reason:"tool_calls"` with one `tool_calls` entry, `id` `call_0_deadbeef`, function name taken from the first tool in the request.
- A temporary ccr provider entry named `solweb-probe` points at the stub. It MUST be removed at the end of the task — the real entry is Task 9's job.

**Behavior:** Start the stub, `bash ~/.claude/workflows/lib/ccr-up.sh solweb` (verified safe: the slug only names the daemon log and is not validated against a provider list), drive one request through ccr with a tool defined, then assert on the recorded body and the client's acceptance of the reply.

**Acceptance (one executable check):**
- Run: `python3 modules/gptbridge/tests/ccr_passthrough_probe.py`
- Expected: prints `PASS tools_forwarded=<n> tool_calls_accepted=true`, exit 0. A non-zero exit means the provider entry needs a `transformer` — record which one in the spec's Registration section and in the session file before Task 9.

- [ ] Write the stub + the probe driver
- [ ] Run the probe through ccr; record the outcome
- [ ] If it fails: determine the required `transformer`, update the spec's Registration block, and note it in `docs/plans/2026-08-08-sol-web-harness.jsonl` as a `session_memory` record
- [ ] Remove the temporary `solweb-probe` ccr provider entry
- [ ] Commit: `git add modules/gptbridge/tests/ccr_passthrough_probe.py && git commit -m "test: prove ccr forwards tools to a bare openai-compatible provider"`

---

### Task 4: Usage-cap detection in `chat.py`

**Wave:** 1
**Blocks:** Task 5
**Blocked by:** —

Cap detection is NEW work — `chat.py` today only steers between the Chat and Work surfaces (`use_chat_surface`) and has no cap probe. **Do not guess the selector: capture it against the live account first.**

**Files:**
- Modify: `modules/gptbridge/chat.py` — add cap detection alongside the existing surface helpers
- Create: `modules/gptbridge/tests/fixtures/cap_dom.html` — the captured cap-state markup (or the documented healthy-state markup if the account is not currently capped)
- Test: `modules/gptbridge/tests/test_cap_detection.py`

**Contract (pin EXACTLY):**
```
detect_cap(m: Marionette) -> CapState | None
    CapState: {"kind": "usage-cap", "resume_at": str | None, "text": str}
    None when the session is healthy.
```
`resume_at` is ISO 8601 when the DOM states a reset time, otherwise `None`.

**Behavior:**
- Reads the live DOM once; no navigation, no side effects.
- Healthy session → `None`. This is the case that MUST hold today, so it is the acceptance check.
- Cap present → `CapState` with the cap text and, when the DOM states a reset time, `resume_at` parsed to ISO 8601.
- If the account is NOT capped at implementation time, say so explicitly in the commit message and pin detection to the markup actually captured — never to an invented selector.

**Acceptance (one executable check):**
- Run: `modules/gptbridge/python3 -m pytest modules/gptbridge/tests/test_cap_detection.py -q`
- Expected: PASS — `detect_cap` returns `None` against the healthy fixture, and a `CapState` against the cap fixture.

- [ ] Open a live session and capture the composer/limit region markup into the fixture
- [ ] Write tests over the fixture(s)
- [ ] Implement `detect_cap`
- [ ] Run the acceptance check
- [ ] Commit: `git add modules/gptbridge/chat.py modules/gptbridge/tests/test_cap_detection.py modules/gptbridge/tests/fixtures/cap_dom.html && git commit -m "feat: detect the chatgpt usage cap from the live dom"`

---

### Task 5: `solwebd` — the daemon

**Wave:** 2
**Blocks:** Task 6, Task 7
**Blocked by:** Task 1, Task 2, Task 4

**Files:**
- Create: `modules/gptbridge/solwebd.py` — HTTP daemon owning one browser session
- Create: `modules/gptbridge/bin/solwebd` — venv launcher, mirroring `modules/gptbridge/bin/ask-gpt`

**Contract (pin EXACTLY):**

HTTP surface, bound to `127.0.0.1` only, default port `8791`:
```
POST /v1/chat/completions   OpenAI shape; `stream` true or false
GET  /v1/models             -> {"data":[{"id":"sol-web-instant","object":"model"},
                                        {"id":"sol-web-medium","object":"model"},
                                        {"id":"sol-web-high","object":"model"},
                                        {"id":"sol-web-xhigh","object":"model"},
                                                                                {"id":"sol-web-pro","object":"model"}]}
GET  /healthz               -> {"ok":bool,"conversations":int,"queue":int,"turn_p50_s":float}
POST /shutdown              releases the profile lock and exits
```
- CLI: `solwebd [--port 8791] [--mode virtual]`.
- Every route, `/healthz` included, requires `Authorization: Bearer <token>` matching `~/.overdeck/gptbridge/solwebd.token` (generated `0600` on first start).
- Model id → effort: the suffix after `sol-web-` must be a key of `chat.EFFORT_LABELS` (`instant|medium|high|xhigh|pro`); anything else → HTTP 400.
- Tool-call responses: `finish_reason:"tool_calls"`, one `tool_calls` entry, `id` = `call_<turn>_<8 hex>`. Final: `finish_reason:"stop"`, text as content.

**Behavior:**
- Owns exactly one browser session for its lifetime and therefore `profile.lock`; the 27s cold start is paid once.
- Serial by construction: one FIFO queue, concurrency 1. `queue` on `/healthz` reports its depth.
- Per request: derive `session_key(messages, tools, effort)`; `Registry.get` or `Registry.open` with `render_preamble`; `deliver` the suffix; **re-assert `set_effort(conv.effort)` immediately before EVERY send** (measured: settable mid-conversation, idempotent on re-assert); read reply blocks from `pre code` elements of the reply turn; `parse_reply`.
- `ReplayNeeded` → open a fresh conversation, render the full history into the preamble, retry once.
- `Malformed` → one correction turn via `correction_prompt` in the same conversation; a second `Malformed` → HTTP 502.
- `stream: true` → SSE: `: ping\n\n` keep-alive every 5s from the moment the request is accepted, then the complete answer as ONE delta chunk, then `data: [DONE]`.
- Failure mapping: session logged out → 503; `detect_cap` returns a `CapState` → 429 with `Retry-After` and `resume_at` in the body; Marionette connection error → rebuild the session once, then 503.
- Everything written stays under `~/.overdeck/gptbridge/`. Never bind `0.0.0.0`.
- Filesystem confinement is Claude Code's own permission system. Do NOT wire in `sandbox.py`/`workspace.py` — they guard the MCP tunnel path, not this one.

**Acceptance (one executable check):**
- Run: `modules/gptbridge/bin/solwebd --port 8799 &` then `curl -sf -H "Authorization: Bearer $(cat ~/.overdeck/gptbridge/solwebd.token)" 127.0.0.1:8799/v1/models`
- Expected: the six `sol-web-*` ids; the same request without the header returns 401.

- [ ] Implement the daemon to satisfy the contract
- [ ] Add `bin/solwebd`, executable, mirroring `bin/ask-gpt`
- [ ] Run the acceptance check (models listed; unauthenticated request 401), then `POST /shutdown`
- [ ] Commit: `git add modules/gptbridge/solwebd.py modules/gptbridge/bin/solwebd && git commit -m "feat: serve the chatgpt web session as an openai-compatible endpoint"`

---

### Task 6: Daemon integration test against a fake browser

**Wave:** 3
**Blocks:** —
**Blocked by:** Task 5

The layer that matters most: everything above the browser must be testable without spending a real ChatGPT turn.

**Files:**
- Create: `modules/gptbridge/tests/fake_browser.py` — a scripted stand-in for the Marionette session
- Test: `modules/gptbridge/tests/test_solwebd_loop.py`

**Contract:**
- `FakeBrowser(script: list[str])` exposes the same surface `solwebd` uses from `chat.py`/`browser.py` (`wait_ready`, `use_chat_surface`, `set_effort`, `type_prompt`, `send`, block extraction, `detect_cap`), returning the next scripted reply per send and recording every typed prompt and every `set_effort` call.
- `solwebd` MUST accept an injected session factory so the test substitutes `FakeBrowser` without monkeypatching internals.

**Behavior under test:**
- A full two-step loop: request with tools → `tool_calls` response → follow-up request carrying the tool result → `stop` response.
- The second request types ONLY the delta, in the pinned turn envelope — not the whole history.
- `set_effort` is called before every send, with the effort from the model id.
- Rewritten history → a fresh conversation is opened and the full history replayed.
- A malformed reply → exactly one correction turn; a second malformed → HTTP 502.
- Scripted cap state → HTTP 429 carrying `resume_at`.
- `stream: true` → keep-alive comments precede the delta chunk and the stream ends with `data: [DONE]`.

**Acceptance (one executable check):**
- Run: `modules/gptbridge/python3 -m pytest modules/gptbridge/tests/test_solwebd_loop.py -q`
- Expected: PASS, no network to chatgpt.com.

- [ ] Write the fake browser and the tests covering the behaviors above
- [ ] Add the session-factory seam to `solwebd` if not already present
- [ ] Run the acceptance check
- [ ] Commit: `git add modules/gptbridge/tests/fake_browser.py modules/gptbridge/tests/test_solwebd_loop.py modules/gptbridge/solwebd.py && git commit -m "test: drive the full sol-web tool loop against a scripted browser"`

---

### Task 7: `sw.sh` — the wrapper-contract CLI

**Wave:** 3
**Blocks:** Task 8, Task 9
**Blocked by:** Task 5

**Read `modules/harness/spec/WRAPPER-CONTRACT.md` first — it is canonical and its exit codes are a hard floor.** Model the structure on `modules/harness/wrappers/na.sh`, which already does ensure-proxy-then-dispatch.

**Files:**
- Create: `modules/harness/wrappers/sw.sh` — the wrapper

**Contract (pin EXACTLY):**
```
sw.sh --workspace <dir> --trust <prompt> --task-slug <slug> [--model sol-web-<effort>]
      [--timeout <secs>] [--profile <slug>] [--session-id <id>] [--health] [--list-models]
```
- `--timeout` defaults to **3600**, not 360. A sol-web task is 10–20s per tool step by measurement; the `na.sh` default would kill nearly every real task.
- `--model` defaults to `sol-web-medium`.
- stdout carries ONLY the final status JSON line; the raw stream goes to a per-run logfile.

Exit-code mapping — MUST NOT be improvised:

| condition | exit |
| --- | --- |
| Claude Code ran to completion | `0` |
| `--timeout` elapsed / killed | `124` |
| bad or missing args | `2` |
| `solwebd` or ccr unreachable, or the ChatGPT session logged out | `3` |
| ChatGPT usage cap hit (daemon 429) | `75`, with `resume_at` in the status JSON |

**Behavior — order of operations:**
1. Ensure `solwebd` is up: probe `/healthz`, start `modules/gptbridge/bin/solwebd` detached if not, wait for ready. Down and unstartable → exit `3`, **no dispatch**.
2. Ensure ccr is up via `bash ~/.claude/workflows/lib/ccr-up.sh solweb`; read the JSON's `up` field, never the exit code. Not up → exit `3`.
3. Run Claude Code in the FOREGROUND under `timeout -k 5 <timeout>`, stdin `</dev/null`, routed at ccr with the requested model.
4. Emit the status JSON line; map the outcome per the table.
- Env scoped to the wrapper's own subprocess — never exported to the caller.
- Fail closed: unexpected state → error with detail, never guess or force.
- The model id appears on the run line and the log line from ONE built array.

**Acceptance (one executable check):**
- Run: `bash modules/harness/wrappers/sw.sh --health`
- Expected: one JSON line reporting daemon and ccr reachability; exit `0` when both are up, `3` when either is down.

- [ ] Implement the wrapper against the contract
- [ ] Run `bash modules/harness/wrappers/_contract-probe.sh modules/harness/wrappers/sw.sh` if that probe accepts a wrapper path; otherwise verify the arg surface by hand against `WRAPPER-CONTRACT.md`
- [ ] Run the acceptance check
- [ ] Commit: `git add modules/harness/wrappers/sw.sh && git commit -m "feat: sw wrapper opens claude code on the sol-web provider"`

---

### Task 8: `sw.sh` exit-code contract test

**Wave:** 4
**Blocks:** —
**Blocked by:** Task 7

**Files:**
- Create: `modules/harness/wrappers/sw-exitcodes.test.sh` — modelled on `modules/harness/wrappers/ca-identity.test.sh`

**Contract:** a bash test that stubs the daemon and ccr and asserts each row of Task 7's exit-code table is reachable.

**Behavior under test:**
- Missing `--workspace` → exit `2` with a `{"ok":false,...}` line.
- Daemon `/healthz` unreachable and `bin/solwebd` stubbed to fail → exit `3`, and Claude Code was NEVER invoked.
- `ccr-up.sh` stubbed to report `{"up":false,...}` → exit `3`, no dispatch.
- Stub daemon returning 429 with `resume_at` → exit `75`, and `resume_at` appears in the status JSON.
- Stub Claude Code that sleeps past a short `--timeout` → exit `124`.
- Stub Claude Code that exits 0 → exit `0`.

**Acceptance (one executable check):**
- Run: `bash modules/harness/wrappers/sw-exitcodes.test.sh`
- Expected: all cases pass, exit `0`.

- [ ] Write the test covering every row of the table
- [ ] Run the acceptance check
- [ ] Commit: `git add modules/harness/wrappers/sw-exitcodes.test.sh && git commit -m "test: every sw exit code is reachable with the daemon stubbed"`

---

### Task 9: Registration — ccr provider and harness adapter

**Wave:** 4
**Blocks:** Task 11
**Blocked by:** Task 3, Task 7

**Files:**
- Modify: `modules/harness/presets/adapters.json` — append one adapter entry
- Modify: `~/.claude-code-router/config.json` (outside the repo, NOT committed) — append one provider entry

**Contract — the adapter entry, verbatim shape:**
```json
{"id": "solweb", "wrapper": "wrappers/sw.sh",
 "description": "ChatGPT web session (Sol) driving Claude Code via ccr.",
 "listModels": true, "metered": false,
 "models": ["sol-web-instant", "sol-web-medium", "sol-web-high",
            "sol-web-xhigh", "sol-web-pro"],
 "safeProbeArgv": ["--health"]}
```

**Contract — the ccr provider entry, verbatim shape:**
```json
{"name": "solweb",
 "api_base_url": "http://127.0.0.1:8791/v1/chat/completions",
 "api_key": "<contents of ~/.overdeck/gptbridge/solwebd.token>",
 "models": ["sol-web-instant", "sol-web-medium", "sol-web-high",
            "sol-web-xhigh", "sol-web-pro"]}
```
Add a `transformer` key ONLY if Task 3 proved one is required, using exactly the transformer Task 3 identified.

**Behavior:**
- `adapters.json` must still validate against `modules/harness/spec/adapters.schema.json` (`additionalProperties: false` — add no key the schema does not define).
- The ccr config edit is a live-machine change, not a commit. Back the file up before editing, and never commit it or reproduce its existing API keys anywhere.
- **Concurrency, stated because the schema cannot express it:** bind any `sol-web-*` model to at most ONE seat per wave. The limit is the browser, so it spans every effort id, not one.

**Acceptance (one executable check):**
- Run: `python3 -c "import json,jsonschema,pathlib; d=json.loads(pathlib.Path('modules/harness/presets/adapters.json').read_text()); s=json.loads(pathlib.Path('modules/harness/spec/adapters.schema.json').read_text()); jsonschema.validate(d,s); print('VALID', [a['id'] for a in d['adapters']])"`
- Expected: `VALID` and `solweb` present in the id list. (Use the repo's existing schema-validation entrypoint if one exists — check `modules/harness/test/adapters-schema.sh` first and prefer it.)

- [ ] Append the adapter entry; validate against the schema
- [ ] Back up `~/.claude-code-router/config.json`, append the provider entry, restart ccr via `bash ~/.claude/workflows/lib/ccr-up.sh solweb`
- [ ] Run the acceptance check
- [ ] Commit: `git add modules/harness/presets/adapters.json && git commit -m "feat: register the solweb adapter"`

---

### Task 10: `ask-gpt` becomes a daemon client

**Wave:** 5
**Blocks:** Task 11
**Blocked by:** Task 5

Today `ask-gpt` opens its own session and takes `profile.lock`. With `solwebd` running that blocks for `LOCK_WAIT_SECONDS` (600) and then fails — the two would fight over the one browser.

**Files:**
- Modify: `modules/gptbridge/ask_gpt.py` — probe the daemon before taking the lock
- Test: `modules/gptbridge/tests/test_ask_gpt_routing.py`

**Contract:**
- `daemon_endpoint() -> str | None` — returns the base URL when `/healthz` answers `ok` with a valid bearer token, else `None`.

**Behavior:**
- Daemon up → the prompt goes through it as a one-shot conversation; `ask-gpt` never touches `profile.lock`.
- Daemon down → today's direct path, byte-for-byte unchanged.
- `ask-gpt "draw me a cat"` keeps working either way. Image harvesting stays in `chat.py` where it already is — do NOT move it.

**Acceptance (one executable check):**
- Run: `modules/gptbridge/python3 -m pytest modules/gptbridge/tests/test_ask_gpt_routing.py -q`
- Expected: PASS — with a stub `/healthz` returning ok, the daemon path is taken and the lock is not acquired; with no daemon, the direct path is taken.

- [ ] Write the routing tests
- [ ] Implement the probe and the daemon path
- [ ] Run the acceptance check
- [ ] Commit: `git add modules/gptbridge/ask_gpt.py modules/gptbridge/tests/test_ask_gpt_routing.py && git commit -m "feat: ask-gpt routes through solwebd when the daemon holds the browser"`

---

### Task 11: Install live, then verify end to end

**Wave:** 6
**Blocks:** —
**Blocked by:** Task 9, Task 10

**Install before landing is the owner's standing rule** (`CLAUDE.md`): this is local infra. Put the working code where it executes and prove it there BEFORE any landing ceremony. A green worktree test is not proof the laptop works.

**Files:** none created; this task installs and verifies.

**Behavior:**
1. Install: run `modules/gptbridge/install.sh` (or the repo's established deploy step for this module) so `bin/solwebd` and the updated `ask_gpt.py` are live on the owner's PATH, and confirm `modules/harness/wrappers/sw.sh` is reachable at the path the harness loads.
2. Invoke the REAL installed entrypoint — not the worktree copy.
3. Report exactly what was verified and what was not.

**Acceptance (one executable check):**
- Run: `sw --workspace "$(mktemp -d)" --task-slug live-check --trust "create hello.txt containing hi"`
- Expected: exit `0`, and `hello.txt` exists in that workspace containing `hi`.

- [ ] Install so the runtime loads the new code
- [ ] Run `ask-gpt "say ok"` with the daemon up — confirm it answers and does not block on the lock
- [ ] Run the acceptance check against the real installed `sw`
- [ ] Report the live results, naming anything not verified
- [ ] Commit any install-side changes: `git add modules/gptbridge/install.sh && git commit -m "chore: install solwebd alongside ask-gpt"` (skip if the installer needed no change)

---

## Notes carried from measurement

- ChatGPT renders fenced code as DOM nodes — **fence markers are ABSENT from `innerText`**. Extract `pre code` elements from the reply turn; a markdown-fence regex silently finds nothing.
- Measured 2026-08-08: 145KB turn-1 send accepted, answered in 16.0s, exactly one valid JSON tool call; turn 2 after an OBSERVATION also one valid block. Warm turn 10–15s, cold start 27.1s.
- Measured 2026-08-08: `set_effort` works on a conversation that already has a turn, and re-asserting the same value is idempotent.
- `ccr-up.sh <slug>` uses the slug ONLY to name its daemon log; it validates nothing against a provider list.
- Pre-existing stray file `modules/gptbridge/%h` is untouched by this plan — flagged, not deleted.
