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/03-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ GET /v1/history?range=… lifetime totals + tool distribution + mod

**Non-Anthropic pricing.** `pricing/pricing.json` carries rows for the models Caprock observes through OpenCode — DeepSeek and MiniMax at the providers' own published rates, fetched with a date and noted in the file. They are priced so a total that includes non-Anthropic usage is a total; before this, $155 of the owner's own spend sat outside his. `normalizeModel` strips a gateway's vendor prefix, so `minimax/minimax-m3` from OpenRouter and `MiniMax-M3` from the direct API are one row rather than two, one of them unpriced. The unpriced warning fires only on turns whose tokens are greater than zero: a turn recorded with explicit zeroes has nothing to price, and warning about it says a total is missing money it is not missing.

**Tool distribution DDL.** `idx_events_kind_ts_tool` on `events(kind, ts, tool)` exists so the ALL TIME panel's tool counts are answered from a covering index. `idx_events_kind_ts` matched the filter but not `tool`, so SQLite fetched ~80k rows from the table to read one column: `ToolDistribution` was 1780 ms cold and 139 ms warm, 60% of `GET /v1/history?range=all`. With the column in the index the same plan is covering — 46 ms warm, measured on the owner's 233k-event database (2026-08-26). The older index is left alone; it serves every other kind+range query and none of them want `tool` along for the ride. Migration `0016_events_tool_dist.sql`.
**Tool distribution DDL.** `idx_events_tool_dist` on `events(kind, ts, tool, tool_bytes)` exists so the ALL TIME panel's tool table is answered from a covering index. Without it SQLite finds rows by (kind, ts) and then fetches each from the table to read two columns — and those rows carry the whole hook payload, which was most of the 2.1s the query took. Covering, it is 0.05s, measured on the owner's 254k-event database (2026-09-02). It replaces `idx_events_kind_ts_tool` from migration 0016, which is the same index without `tool_bytes`; both would cost two copies of the same thing on every insert, so `0019_drop_superseded_tool_index.sql` drops it — as its own migration, because 0018 had already run where it mattered and an edit to an applied migration is an edit nobody receives. `idx_events_kind_ts` is left aloneit serves every other kind+range query and none of them want `tool` along for the ride. Migrations `0018_tool_bytes.sql`, `0019_drop_superseded_tool_index.sql`.

**`caprock license` manages the key from the terminal** — `license` shows it, `license set <key>` stores it (refusing one that will not work rather than leaving someone to wonder why nothing happened), `license clear` removes it, and `license issue --days N | --lifetime` mints one. Issuing exists because the Stripe webhook was the only thing that could make a key, which leaves no way to serve a customer who paid another way, a refund reissued, or a friend. `license.Issue` and the webhook produce the same format and a cross-repository test holds them together. The random suffix is optional: nothing verifies it, it exists so two keys issued on the same day are distinguishable in an email, and a key dictated over the phone has to work without it.

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

Phase 3 (Delight) has no plan by design.

## [0.47.1] - 2026-09-02

### Fixed

- **Every table says what its columns are.** Three figures in a row with
nothing over them is a puzzle: `$7,587.14 · 12.01B · 58%` reads as three
unrelated numbers until you work out which is which, and the "by cost" in the
corner describes the sort order rather than the columns. Every breakdown on
Now, Cost and Lifetime now names them, on one line with the table's own title
so it costs no vertical space.

The tool table has two figures rather than three, and now says so. It could
have shown tokens beside calls — but 14% of Bash calls cannot be linked to
the turn that paid for them against 1% of Read's, so the number would have
understated Bash specifically, which is the comparison the table exists to
make. A figure that looks measured and is quietly skewed is worse than a
column that is not there.

- **The all-time breakdown moved up, under the live pulse.** It was last, below
a tall activity feed, so the most legible thing on the screen was the thing
you had to scroll to find.

- **"Nothing measured yet" appeared while the figures were still loading.** The
first screen could not tell "no answer yet" from "the answer is nothing", so
for the second it took the first response to arrive it announced, with
confidence, that a machine with thousands of turns on it had never run
anything. It says "reading your figures…" until it knows. Measured on a
641 MB database: nothing on this screen takes longer than 0.7s, so the delay
was never the problem — the wrong answer during it was.

- **The answers screen showed raw Markdown.** Claude writes bold text, lists
and tables; the screen kept the line breaks and showed everything else as the
markup it is made of — `**bold**` with its asterisks, a table collapsed into
`| | |`. The same renderer the release notes got now covers both, and it
learned tables and quotes on the way.

- **A Gemini error sent people somewhere they no longer need to go.** "Set
GEMINI_API_KEY in the daemon's environment and restart it" — but the key has
a field on the Cost screen and takes effect at once. A wrong instruction is
worse than a long one.

- **Errors carried a Go package name into the dialog box.** `agents: "/x" is
not an absolute path` was written for whoever was debugging it.

### Added

- **Share a week, a month, or today — not only all time.** The picker was
removed once, on the reasoning that a card showing every period at once made
choosing redundant. It does not: somebody sharing a working week does not
want their lifetime total to be the headline, and a card that answers four
questions answers none of them loudly. The choice now decides what the
heading claims and which figure is lit; every other period stays on the card,
because a week means nothing without knowing whether it was a normal one.
It opens on the week.

- **You can see the card before you send it.** The buttons said where the
picture would go and nothing said what was in it: you pressed Save, opened
your downloads, and only then found out. The card is drawn in the sheet now,
at the size it will be seen, and it redraws as you change the period — which
is what makes the choice worth having.

- **The Share button can be found.** It was an outlined button in a row of
11px text, next to a solid premium button — an outline cannot win an argument
with a fill. It is filled, a size up, and carries an icon. It earns that:
sharing is the only thing on this dashboard that leaves the machine.

### Changed

- **Copy across the product says less.** Several tooltips explained the
attribution algorithm where the reader had asked what a number meant, and
several paragraphs defended design decisions nobody had questioned. The
longest of them — the per-directory rule — was four sentences of
specification on hover; it is now one. "Everything here is measured — no
invented numbers" is gone from the Cost screen: readers assume the figures
are real until told otherwise, so the sentence created the doubt it answered.

- **Messages about a missing `claude` say what to do.** "Spawning is
unavailable" named an internal verb and diagnosed without a fix; it now says
the binary is not on PATH, that installing Claude Code fixes it, and that
watching works regardless.

## [0.47.0] - 2026-09-02

### Added
Expand Down
8 changes: 3 additions & 5 deletions cmd/caprock/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,8 +271,7 @@ func maybeInstallHooks(cmd *cobra.Command, dir string, yes bool) error {
return nil
}
if !yes {
fmt.Fprintf(cmd.OutOrStdout(), "Caprock needs a hook entry in %s so it can see Claude Code sessions.\n", sp)
fmt.Fprintf(cmd.OutOrStdout(), "It appends `%s` under %d events, backs the file up first, and never touches your other hooks.\n", shimPath, len(hooks.Events))
fmt.Fprintf(cmd.OutOrStdout(), "Caprock adds a hook to %s so it can see your sessions. It backs the file up first.\n", sp)
if !confirm(cmd, "Install now? [Y/n] ") {
fmt.Fprintln(cmd.OutOrStdout(), "Skipped. Run `caprock hooks install` later; transcript tailing still works (delayed).")
return nil
Expand Down Expand Up @@ -332,8 +331,7 @@ func maybeInstallStatusline(cmd *cobra.Command, yes bool) error {
return nil
}
if !yes {
fmt.Fprintf(cmd.OutOrStdout(), "Caprock can also show your plan limits (5h/7d, Pro/Max) on the Cost screen via Claude Code's status line.\n")
fmt.Fprintf(cmd.OutOrStdout(), "It sets `statusLine.command` to `%s` in %s (backed up first). Skip if you use your own status line.\n", cmdStr, sp)
fmt.Fprintf(cmd.OutOrStdout(), "Caprock can also show your plan limits on the Cost screen, through Claude Code's status line (backed up first).\n")
if !confirm(cmd, "Add it? [Y/n] ") {
fmt.Fprintln(cmd.OutOrStdout(), "Skipped. Run `caprock statusline install` later to enable plan limits.")
return nil
Expand Down Expand Up @@ -470,7 +468,7 @@ func statusCmd() *cobra.Command {
if st.ClaudeAvailable {
fmt.Fprintf(out, "claude: found on PATH — Caprock can start sessions for you\n")
} else {
fmt.Fprintf(out, "claude: not found on PATH — Caprock cannot start sessions, but still observes every session you start yourself\n")
fmt.Fprintf(out, "claude: not on PATH — install Claude Code to start sessions here; watching still works\n")
}
fmt.Fprintf(out, "ui: %s\n", map[bool]string{true: "embedded", false: "placeholder (built without dashboard)"}[st.UIBuilt])
// Which hive is in force was reported nowhere — not here, not in
Expand Down
24 changes: 12 additions & 12 deletions internal/agents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,11 +236,11 @@ func resolveClaude() string {
// without touching anyone's repository.
func (m *Manager) newChatDir() (string, error) {
if m.dataDir == "" {
return "", errors.New("agents: no data directory, so a chat has nowhere to live")
return "", errors.New("no data directory, so a chat has nowhere to live")
}
base := config.ChatsDir(m.dataDir)
if err := os.MkdirAll(base, 0o700); err != nil {
return "", fmt.Errorf("agents: create chats directory: %w", err)
return "", fmt.Errorf("create chats directory: %w", err)
}
// Second granularity plus a counter: two chats started inside the same
// second would otherwise land in one directory and share a transcript.
Expand All @@ -254,10 +254,10 @@ func (m *Manager) newChatDir() (string, error) {
if err := os.Mkdir(dir, 0o700); err == nil {
return dir, nil
} else if !os.IsExist(err) {
return "", fmt.Errorf("agents: create chat directory: %w", err)
return "", fmt.Errorf("create chat directory: %w", err)
}
if i > 100 {
return "", errors.New("agents: could not find a free chat directory name")
return "", errors.New("could not find a free chat directory name")
}
}
}
Expand All @@ -275,24 +275,24 @@ func (m *Manager) newChatDir() (string, error) {
// asked for a directory to exist and it does.
func makeProjectDir(dir string) error {
if !filepath.IsAbs(dir) {
return fmt.Errorf("agents: %q is not an absolute path", dir)
return fmt.Errorf("%q is not an absolute path", dir)
}
// Clean first: `/Users/me/dev/../../../etc/x` is absolute and still escapes
// wherever the user thought they were.
dir = filepath.Clean(dir)
parent := filepath.Dir(dir)
if parent == dir {
return fmt.Errorf("agents: refusing to create the filesystem root")
return fmt.Errorf("refusing to create the filesystem root")
}
fi, err := os.Stat(parent)
if err != nil || !fi.IsDir() {
return fmt.Errorf("agents: %q does not exist, so %q cannot be created in it", parent, filepath.Base(dir))
return fmt.Errorf("%q does not exist, so %q cannot be created in it", parent, filepath.Base(dir))
}
// Mkdir, never MkdirAll — the parent check above is the guard, and this
// call is what keeps it a guard. MkdirAll here would create the chain the
// check just refused, so the two have to stay in agreement.
if err := os.Mkdir(dir, 0o755); err != nil && !os.IsExist(err) {
return fmt.Errorf("agents: could not create %q: %w", dir, err)
return fmt.Errorf("could not create %q: %w", dir, err)
}
return nil
}
Expand All @@ -309,11 +309,11 @@ func (m *Manager) Spawn(ctx context.Context, req SpawnRequest) (*Agent, error) {
req.Cwd = dir
}
if req.Cwd == "" {
return nil, errors.New("agents: spawn without cwd")
return nil, errors.New("spawn without cwd")
}
if fi, err := os.Stat(req.Cwd); err != nil || !fi.IsDir() {
if !req.Create {
return nil, fmt.Errorf("agents: cwd %q is not a directory", req.Cwd)
return nil, fmt.Errorf("cwd %q is not a directory", req.Cwd)
}
if err := makeProjectDir(req.Cwd); err != nil {
return nil, err
Expand Down Expand Up @@ -421,7 +421,7 @@ func (m *Manager) Spawn(ctx context.Context, req SpawnRequest) (*Agent, error) {
// when the caller's context (e.g. an HTTP request) ends.
sess, err := m.pty.Spawn(context.WithoutCancel(ctx), spec)
if err != nil {
return nil, fmt.Errorf("agents: spawn %s: %w", command, err)
return nil, fmt.Errorf("spawn %s: %w", command, err)
}
a := &Agent{
SessionID: sessionID, Cwd: cwd, Worktree: worktree, Command: command + " " + join(args), StartedAt: time.Now(),
Expand Down Expand Up @@ -590,7 +590,7 @@ func (m *Manager) Shutdown() {
}

// ErrNotOwned is returned for control operations on a session Caprock did not spawn.
var ErrNotOwned = errors.New("session is not owned by caprock (observe-only)")
var ErrNotOwned = errors.New("caprock did not start this session, so only its own terminal can control it")

func errNotOwned(id string) error { return fmt.Errorf("%w: %s", ErrNotOwned, id) }

Expand Down
4 changes: 2 additions & 2 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1097,7 +1097,7 @@ func (s *Server) requireTasks(w http.ResponseWriter) bool {
// "Phase 2" is our internal build order and means nothing to a user;
// the detail says what to do instead. It no longer sends anyone to a
// terminal first: POST /v1/hive turns the runner on where they are.
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "the task runner is off", "detail": "turn it on with POST /v1/hive, or start the daemon with `caprock up --hive <dir>`"})
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "the task runner is off", "detail": "turn it on from the Tasks screen, or start the daemon with `caprock up --hive <dir>`"})
return false
}
return true
Expand Down Expand Up @@ -1255,7 +1255,7 @@ func (s *Server) handleApprovals(w http.ResponseWriter, r *http.Request) {

func (s *Server) requireAgents(w http.ResponseWriter) bool {
if s.d.Agents == nil || !s.d.Agents.Available() {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "spawning is unavailable", "detail": "the `claude` binary was not found on PATH; Caprock runs in observe-only mode"})
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "can't start sessions", "detail": "`claude` is not on your PATH — install Claude Code, or add it. Caprock still watches sessions you start yourself."})
return false
}
return true
Expand Down
101 changes: 0 additions & 101 deletions internal/api/dist/assets/index-C_Ali4sj.js

This file was deleted.

1 change: 1 addition & 0 deletions internal/api/dist/assets/index-D7qL0sQJ.css

Large diffs are not rendered by default.

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

This file was deleted.

101 changes: 101 additions & 0 deletions internal/api/dist/assets/index-PB3QKXS-.js

Large diffs are not rendered by default.

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-C_Ali4sj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-JiLc92x0.css">
<script type="module" crossorigin src="/assets/index-PB3QKXS-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7qL0sQJ.css">
</head>
<body>
<div id="root"></div>
Expand Down
4 changes: 2 additions & 2 deletions internal/api/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ func (s *Server) handleGeminiAsk(w http.ResponseWriter, r *http.Request) {
if !gemini.Available(s.d.Settings.Get().GeminiAPIKey) {
// Not an error the user did wrong: the feature is simply not set up.
writeJSON(w, http.StatusPreconditionFailed, map[string]string{
"error": "no api key",
"detail": "set " + gemini.EnvKey + " in the daemon's environment and restart it",
"error": "no Gemini key",
"detail": "add one on the Cost screen — it works straight away, no restart",
})
return
}
Expand Down
2 changes: 1 addition & 1 deletion internal/board/board.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ func (cr *CreateRequest) validate() error {
case len(cr.Body) > maxBody:
return fmt.Errorf("task body is %d bytes; the limit is %d", len(cr.Body), maxBody)
case len(cr.DoneCriteria) == 0:
return errors.New("done_criteria is required: at least one command that must pass before the task can be done. Caprock cannot verify a task without one, and will not mark it done on the worker's say-so")
return errors.New("done_criteria is requiredat least one command that must exit 0 before the task is done")
case math.IsNaN(cr.BudgetUSD) || math.IsInf(cr.BudgetUSD, 0):
return errors.New("budget_usd must be a real number")
case cr.BudgetUSD < 0:
Expand Down
8 changes: 4 additions & 4 deletions internal/license/license.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,22 +63,22 @@ func Parse(key string, now time.Time) State {
return State{Reason: "no key"}
}
if !strings.HasPrefix(k, Prefix) {
return State{Reason: fmt.Sprintf("a Caprock key starts with %q", Prefix)}
return State{Reason: fmt.Sprintf("that is not a Caprock key — they start with %q", Prefix)}
}
rest := strings.TrimPrefix(k, Prefix)
// A date is all a key needs. The suffix exists so two keys issued on the
// same day can be told apart in an email; nothing verifies it, so a key
// dictated over the phone and typed without one still has to work.
if len(rest) < len("2006-01-02") {
return State{Reason: "key is too short to carry a date"}
return State{Reason: "that key looks cut short — check you copied all of it"}
}
day := rest[:len("2006-01-02")]
exp, err := time.Parse("2006-01-02", day)
if err != nil {
return State{Reason: "key does not carry a readable date"}
return State{Reason: "that key is not readable — check you copied all of it"}
}
if len(rest) > len(day) && rest[len(day)] != '-' {
return State{Reason: "key is missing the separator after its date"}
return State{Reason: "that key is not readable — check you copied all of it"}
}
// The date names the last day covered, so the key is good until the end of
// it — an expiry of 2026-08-26 that stops working at midnight on the 26th
Expand Down
8 changes: 4 additions & 4 deletions internal/license/license_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ func TestParse(t *testing.T) {
// Every rejection has to say what is wrong. "Invalid" sends someone to
// support; naming the problem lets them fix it.
{name: "no key at all", key: "", now: "2026-08-26", wantWhy: "no key"},
{name: "the wrong kind of string", key: "sk_live_abc123", now: "2026-08-26", wantWhy: "starts with"},
{name: "prefix but no date", key: "CR-hello", now: "2026-08-26", wantWhy: "too short"},
{name: "a date that is not one", key: "CR-2026-13-45-A1B2C3D4", now: "2026-08-26", wantWhy: "readable date"},
{name: "date runs into the random part", key: "CR-2026-08-26A1B2C3D4", now: "2026-08-26", wantWhy: "separator"},
{name: "the wrong kind of string", key: "sk_live_abc123", now: "2026-08-26", wantWhy: "not a Caprock key"},
{name: "prefix but no date", key: "CR-hello", now: "2026-08-26", wantWhy: "cut short"},
{name: "a date that is not one", key: "CR-2026-13-45-A1B2C3D4", now: "2026-08-26", wantWhy: "not readable"},
{name: "date runs into the random part", key: "CR-2026-08-26A1B2C3D4", now: "2026-08-26", wantWhy: "not readable"},
} {
t.Run(tc.name, func(t *testing.T) {
got := Parse(tc.key, at(tc.now))
Expand Down
Loading
Loading