Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .ai/02-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ type Event struct {
}
```

Append-only `events` table + materialized rollups (`session_stats`, `agent_stats`, `daily_stats`). DDL in [03-contracts.md § SQLite schema](03-contracts.md#sqlite-schema-ddl-v1). Note the DDL v1 `events` row carries `source` (`hook` | `transcript`) in addition to the fields above, because the same session is seen through both planes; the planes are reconciled by shared dedupe keys ([03-contracts.md § Hook shim](03-contracts.md#hook-shim)). One extra kind exists in code: `context.compact` (PreCompact hooks). `SessionStart` maps to `agent.spawn`, `SessionEnd` to `session.end`, `Stop`/`SubagentStop` to `agent.stop` (subagents carry `agent_id`).
Append-only `events` table + materialized rollups (`session_stats`, `agent_stats`, `daily_stats`). DDL in [03-contracts.md § SQLite schema](03-contracts.md#sqlite-schema-ddl-v1). Note the DDL v1 `events` row carries `source` (`hook` | `transcript`) in addition to the fields above, because the same session is seen through both planes; the planes are reconciled by shared dedupe keys ([03-contracts.md § Hook shim](03-contracts.md#hook-shim)). One extra kind exists in code: `context.compact` (PreCompact hooks). `SessionStart` maps to `agent.spawn`, `SessionEnd` to `session.end`, `Stop`/`SubagentStop` to `agent.stop` (subagents carry `agent_id`). Gemini answers are recorded as an ordinary `turn.user` + `turn.assistant` pair with `source = gemini`, so they are priced by the same table, searched by the same index and filtered by the same `agent` column as everything else ([ADR-023](08-decisions.md)).

Every producer writes through one path — `internal/rollup.Recorder.Record` — which stores the event, upserts the session, prices assistant turns, updates `session_stats` / `daily_stats` / `session_files` in one transaction, then publishes `event` + `session` frames on the in-process bus (`internal/bus`) that feeds the WebSocket and the loop detector.

Expand Down
2 changes: 2 additions & 0 deletions .ai/03-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ Plan-limit windows relayed by `caprock statusline` are **validated before storag

What that file holds bounds what may ever be said about it: a timestamp and two window percentages. **No tokens, no cost, no conversation content**, so this can never state what the desktop app cost — only how much of a window it consumed. It is also written only while the app runs (27 samples in a day on one real machine, against ~290 at its five-minute interval), so a reading older than 20 minutes is flagged `stale` and the UI says the app has been closed since. Nothing is stored and nothing is polled.

`GET /v1/gemini` reports whether asking Gemini is possible here: `{available, env_var, licensed, model}`. It performs **no network I/O** and **never returns the key** — `available` says only that one is present. `POST /v1/gemini/ask` takes `{prompt, model?}` and answers `{text, model, usage}`, where `usage` carries the response's own `promptTokenCount` / `candidatesTokenCount` / `cachedContentTokenCount` / `thoughtsTokenCount`. It is the one endpoint in the product that checks the licence **server-side** (402 without an active key) rather than leaving the paywall to the UI, because the call spends the user's Gemini quota and opens an outbound connection — the reasoning and its limits are in [ADR-023](08-decisions.md). With no key set it answers 412 with the variable to set, which is a different problem from 402 and is reported separately so the screen can say which. The key is read from `GEMINI_API_KEY` in the daemon's environment at call time; it is never stored, never accepted by `PUT /v1/settings`, and never present in `GET /v1/settings`.

`GET /v1/update` returns `{enabled, current, latest, update_available, command, url, checked_at, error, notes, notes_for}` from cache and **performs no network I/O** — a page load must never cause an outbound call. `POST /v1/update/check` performs one, and returns **403 while `update_checks` is false**: the opt-in is enforced by the server, not merely hidden in the UI, so no page or local script can make Caprock reach the network uninvited. Checks are throttled to once a day unless forced, the request carries no body or credentials, and a failure is reported in `error` rather than as an error status — not knowing about a release must not read as a broken dashboard. `command` is the upgrade command inferred from the running binary's path (Homebrew, Scoop, `go install`); when no package manager owns the binary it is empty and the UI offers `url` instead. `notes` is the published release's own description, taken from the same GitHub response as the tag — reading it costs no second request and no further exposure. It is trimmed to a dialog-sized excerpt (long bodies cut at a line boundary) and paired with `notes_for`, the version it describes, so a cached note can never be shown beside a different version after a failed check. `update_available` is never true for a `dev` or `git describe` build. Caprock does not install the update: replacing the running binary would mean the daemon killing the process executing the command, and running a package manager on the user's behalf from a web page is a surface a local tool should not open.

**`PUT /v1/settings` is a patch, not a replace.** Fields are decoded as pointers, so a body changes only the keys it names and leaves the rest as they were; `PUT {}` is a no-op. An explicit `false` is still honoured, so nothing here is write-only. This is not a convenience: decoding into a plain struct made an absent field indistinguishable from a cleared one, so a short body — or a retry that dropped fields — answered 200 while resetting the stated plan *and* switching the release-check opt-in off. The plan decides what every cost figure on the dashboard claims to be, and `update_checks` gates rule 4's single outbound call; neither may be toggled by omission.
Expand Down
68 changes: 68 additions & 0 deletions .ai/08-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,3 +354,71 @@ switch on. Anything that needs our infrastructure — cross-machine aggregation,
the weekly report's delivery — is enforced by that infrastructure and needs no
key at all, which is the tier boundary [ADR-021](#adr-021--the-team-tier-is-self-hosted-and-the-free-product-is-not-carved-up)
already draws.

---

## ADR-023 — Gemini runs on a key Caprock never holds, read from the environment

**Decided 2026-09-01.** *Second paid feature.*

A user can point Caprock at Google's Gemini through their own Google AI Studio
key. Caprock makes the call; the user pays Google directly. Two decisions shape
it, and both exist to keep the product's foundation intact.

**Caprock never stores the key.** It is read from `GEMINI_API_KEY` in the
daemon's environment, the way every CLI tool on the machine already does it —
never written to `config.json`, never accepted by `PUT /v1/settings`, never
returned by `GET /v1/settings`. This is the direct answer to the objection
recorded in [17-teams.md](17-teams.md) § Not a secret store: *"a bug in Caprock
shows a wrong number, and with a vault a bug in Caprock leaks credentials."*
A key held in the environment cannot leak from a database Caprock does not
write. It also rules out the alternative — an OS keychain across three
platforms — which is real work, a Windows CI surface, and still leaves Caprock
custodian of somebody's credential.

The cost is honest and worth naming: the user sets an environment variable
before starting the daemon, which is a worse first run than pasting a key into
a field. That is the price of not being a secret store, and it is the right
trade for a tool whose whole argument is that it holds nothing.

**Rule 4 gains its second exception, and it is opt-in per call.** [Rule
4](../CLAUDE.md) says all data stays on the machine, with the release check as
the only exception — an outbound call that carries nothing about the user. A
Gemini call carries both a credential and the user's own content, which is
categorically further than that exception reaches, so it is written down here
rather than assumed. What keeps it inside the spirit of the rule: nothing is
sent unless the user asks a question in that turn, no background call is ever
made, the destination is Google's documented endpoint and nowhere else, and
with the variable unset the feature does not exist — there is no default-on
path to disable. Caprock still sends nothing about the user to Caprock.

**The gate is checked on the server, unlike the spend cap.** [ADR-022](#adr-022--the-licence-key-is-an-offline-string-with-an-expiry-and-nothing-more)
made the licence a convenience rather than a lock, and the spend cap follows
that: its paywall is a React component, and a free user who sets the threshold
by curl gets a working cap. Copying that here would be wrong. The cap spends
nothing; a Gemini call spends the user's quota and opens an outbound
connection, so an unpaid caller is not merely reading a screen they did not pay
for. `license.Parse(...).Active` is therefore checked in the handler before the
request leaves, which is a new precedent in this codebase and deliberately
narrow: it applies to features that spend money or reach the network, not to
features that draw a panel.

**Usage is counted from the response, not from Google.** There is no per-key
billing API — Google's own answer is that per-key breakdowns "can't be done via
AI Studio usage dashboards", and the console reports per *project*. So the
figures come from `usageMetadata` on each response (`promptTokenCount`,
`candidatesTokenCount`, `cachedContentTokenCount`, `thoughtsTokenCount`),
priced through the same `pricing/` table as everything else and stamped with
the same basis. Two consequences are stated on screen rather than hidden: the
history starts when the feature is first used, because nothing before that
passed through Caprock, and the total is what Caprock sent, not what Google
billed.

**Rules out:** storing the key in `config.json` or any Caprock-managed store;
an OS keychain; reading Google's usage dashboard; any background or speculative
call; presenting a Caprock-side total as the user's Google bill.

**Revisit if** Google ships a per-key usage API (then the numbers can be
reconciled rather than only counted), or if setting an environment variable
proves to be the thing that stops people using the feature — in which case the
question is a better handoff, not a key Caprock keeps.
48 changes: 48 additions & 0 deletions .ai/14-build-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,54 @@ Percentages are deliberately coarse — they answer "is this track started, half

## Log

### 2026-09-01 — A second model, on a key we refuse to hold

**Gemini is the second paid feature, and the shape of it is one decision:
the key is not ours.** It is read from `GEMINI_API_KEY` in the daemon's
environment at call time — never written to `config.json`, never accepted by
`PUT /v1/settings`, never returned by any endpoint, and sent in a header rather
than a query string so nothing that logs a URL can capture it. The objection
this answers was already written down in [17-teams.md](17-teams.md): *"a bug in
Caprock shows a wrong number, and with a vault a bug in Caprock leaks
credentials."* A key held in the environment cannot leak from a database Caprock
does not write. The price is a worse first run — set a variable, restart — and
the panel says so rather than hiding a missing field.

**The licence is checked on the server, which the spend cap deliberately does
not do.** [ADR-022](08-decisions.md) made the key a convenience rather than a
lock, and the cap follows it: a free user who sets the threshold by curl gets a
working cap, because a cap spends nothing. This spends the user's Gemini quota
and opens an outbound connection, so the check runs in the handler before the
request leaves. A test asserts an unlicensed ask never reaches the client.
[ADR-023](08-decisions.md) keeps the precedent narrow on purpose: server-side
gates belong to features that spend money or reach the network, not to features
that draw a panel.

**What makes it worth paying for is the context, not the chat box.** Asked "what
did I spend yesterday" with no figures, a model can only explain what spending
is. Every question now carries today's and the week's totals, the top models and
projects, and what is running — all already computed. What it does *not* carry
is prompts, replies, tool output and file paths, each pinned by its own test:
the database holds the prose Claude wrote and every command it ran, and none of
that belongs in a request the user did not specifically ask for.

**The model is chosen and priced before it is spent.** A question costs 0.04
cents on Flash Lite and 1.00 cent on Pro — twenty-five times, on the user's own
card — so being locked to whichever default we picked was our choice made with
their money. The list is built from the pricing table, so one place adds a model
and the price shown is the price charged. Sub-cent figures render in cents:
`fmtUSD` prints `$0.002` as `$0.00`, and a reader who believes a question is
free will believe it fifty times.

**Two traps worth recording.** A `useApi` poll on the status endpoint outlived
the test that mounted it and fired into a torn-down jsdom — the key comes from
the environment and cannot change while the daemon runs, so the timer bought
nothing and was removed. And five minutes went into a model list that stayed
empty on a scratch daemon: a process from 21 August was still holding port 4199,
and every request was being answered by a binary three weeks old. The handler
test had been green the whole time.


### 2026-08-31 — The first paid feature, a folder picker, Shift+Enter on the fourth attempt, and a session that ended half a day late

**The daily spend cap is built** (`internal/cap`) — the first thing Caprock does rather than shows. A limit for the day; when the day crosses it, the sessions Caprock started are paused. Four rules, each with a test verified by breaking it: only owned sessions (`agents.PauseOwned` refuses any id the manager did not spawn, so [rule 7](../CLAUDE.md) lives in the thing holding the process handles); paused rather than killed, so a resume keeps the conversation; once a day, with the day claimed under a mutex before any signal; and fails open, because a missed pause costs money while a spurious one stops work that was fine. The suggested limit is twice the reader's median day — the median rather than the mean, since one runaway day would drag an average up and produce a ceiling that never fires, which is exactly the day the feature exists for.
Expand Down
7 changes: 7 additions & 0 deletions .ai/17-teams.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ and the OS keychain — tools that do nothing else — and it would put every
buyer's security review in front of a two-person product. Helping someone not
leak a key is worth paying for; taking custody of it is a different company.

*This held when a feature needed a key.* The Gemini feature ([ADR-023](08-decisions.md))
reads the user's Google AI Studio key from `GEMINI_API_KEY` in the daemon's
environment at the moment of the call — never stored, never in `config.json`,
never returned by any endpoint. The paragraph above is the reason: a key Caprock
does not hold is a key Caprock cannot leak. The cost is a worse first run, and
that was the right side of the trade.

This is a candidate, not a decision. What premium contains should be settled by
what the first paying users ask for.

Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ polish (plan-limit windows, orchestrator-lifecycle fixes, Homebrew formula, firs

## [Unreleased]

### Added

- **Ask Gemini, on your own key.** The second paid feature: a second model
inside Caprock, billed to you by Google at their prices. Caprock **never
stores the key** — it reads `GEMINI_API_KEY` from the daemon's environment at
the moment of the call, and it is never written to disk, never accepted by the
settings endpoint and never returned by one. A key we do not hold is a key we
cannot leak; the cost is that you set a variable and restart, which the panel
says plainly.

Questions travel with your own figures — today's and the week's spend, the top
models and projects, what is running now — so the answers are about your
machine rather than about software in general. Prompts, replies, tool output
and file paths are never sent.

The model is yours to pick, priced before you spend: a question costs about
0.04 cents on Flash Lite and 1.00 cent on Pro. Answers are counted and priced
beside your Claude spend, from the response's own token counts — which is what
Caprock sent, not what Google billed, since there is no per-key billing API to
reconcile against.

Phase 3 (Delight) has no plan by design.

## [0.40.0] - 2026-08-31
Expand Down
4 changes: 3 additions & 1 deletion internal/api/agent_param_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ func TestAgentFilterParam(t *testing.T) {
{"all", "", false},
{"claude", "claude", false},
{"opencode", "opencode", false},
{"gemini", "gemini", false},
{"OpenCode", "", true}, // case matters; a near-miss must not silently widen
{"gemini", "", true},
{"Gemini", "", true},
{"cursor", "", true}, // an agent we do not support is an error, not "everything"
{"'; DROP TABLE sessions--", "", true},
{"claude,opencode", "", true},
}
Expand Down
9 changes: 7 additions & 2 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ type Deps struct {
Token string
// Shutdown is invoked by POST /v1/shutdown (caprock down).
Shutdown func()
// AskGemini answers one prompt on the user's own key and records what it
// cost. nil ⇒ the endpoint returns 501. See ADR-023.
AskGemini func(ctx context.Context, model, prompt string) (any, error)
// Agents is the Phase 1 owned-session manager (nil ⇒ endpoints return 501).
Agents AgentController
// Tasks is the Phase 2 hive-backed task board (nil ⇒ endpoints return 501).
Expand Down Expand Up @@ -203,6 +206,8 @@ func New(d Deps) *Server {
// rather than duplicated in the UI, so one edit in Go changes every place
// a price appears.
m.HandleFunc("GET /v1/premium", s.handlePremium)
m.HandleFunc("GET /v1/gemini", s.handleGeminiStatus)
m.HandleFunc("POST /v1/gemini/ask", s.handleGeminiAsk)
m.HandleFunc("GET /v1/pricing", s.handlePricing)
m.HandleFunc("GET /v1/live", s.ws.ServeHTTP)
if d.Hook != nil {
Expand Down Expand Up @@ -1396,9 +1401,9 @@ func agentFilter(v string) (store.AgentFilter, error) {
switch v {
case "", "all":
return "", nil
case "claude", "opencode":
case "claude", "opencode", "gemini":
return store.AgentFilter(v), nil
default:
return "", fmt.Errorf("unknown agent %q: use claude, opencode, or omit for both", v)
return "", fmt.Errorf("unknown agent %q: use claude, opencode, gemini, or omit for all", v)
}
}
1 change: 1 addition & 0 deletions internal/api/dist/assets/index-AgZMyFDM.css

Large diffs are not rendered by default.

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion internal/api/dist/assets/index-shYzKSwp.css

This file was deleted.

4 changes: 2 additions & 2 deletions internal/api/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
} catch (e) {}
})();
</script>
<script type="module" crossorigin src="/assets/index-BZxreaR-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-shYzKSwp.css">
<script type="module" crossorigin src="/assets/index-D7o1sDFB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-AgZMyFDM.css">
</head>
<body>
<div id="root"></div>
Expand Down
Loading
Loading