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
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ status inferred from output and no sibling awareness.
| `note` | Append to the project's shared log |
| `notes` | Read what other agents recorded |
| `work` | Read what another session has changed — summary and patch |
| `analyse` | Have a separate agent review a session's work; returns an id |
| `analysis` | Collect that review, with what it cost |
| `message` | Send to one sibling by name, or to all of them |
| `inbox` | Collect messages sent to you |

Expand All @@ -212,7 +214,24 @@ and submitting on its behalf would let one agent put instructions into
another's prompt with nobody watching. The cost of pulling is that the
recipient has to ask, so every other tool result carries an unread count.

The sidebar shows `⊙ n` for claims held and `✉ n` for messages waiting.
An agent can also read a sibling's work directly, and have it reviewed. `work`
returns the summary and patch of another session's worktree, which needs
nothing from that session — it is read while that agent is mid-turn and
interrupts nothing. `analyse` goes further and starts a **separate agent** to
review it.

That review is asynchronous, because a real one outlasts the tool timeout of
whatever asked for it, and because a blocking call is invisible exactly while
you would want to watch it. The reviewer is handed the diff, given no tools,
and run in a mode that answers but cannot act. `analysis` collects the answer
along with what it cost.

The sidebar shows `⊙ n` for claims held, `✉ n` for messages waiting, and
`⚗ n · $x.xx` for reviews running and what they have cost. That last one is
the only thing in Deck that spends money with nobody watching it: the review
has no pane, and the session that asked for it has moved on. The figure stays
after the last review finishes, so a total is not lost the moment it stops
moving.

## Status

Expand Down
48 changes: 47 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ internal/agent PTY + vt emulator per session <- the subtle package
render.go the cell walk
status.go working / idle / exited, and how quiet
env.go what a hosted agent must not inherit
headless.go one turn with no pane, and what it cost
internal/coord cross-session coordination, exposed to agents over MCP:
coord.go types + session lifecycle
claims.go soft locks and who holds what
Expand All @@ -54,7 +55,9 @@ internal/coord cross-session coordination, exposed to agents over MCP:
status.go turn state reported by hooks
mcp.go the listener and the hook endpoint
rpc.go JSON-RPC envelopes and framing
tools.go the tool schemas and dispatch
tools.go the tool schemas; dispatch.go answers a call
work.go reading a sibling's changes
analyse.go starting a spawned review; jobs.go records it
results.go tool results and the unread-mail hint
internal/ui the Bubble Tea program, split by job:
model.go the model and its builders
Expand All @@ -73,6 +76,8 @@ internal/ui the Bubble Tea program, split by job:
browser.go the directory explorer; dirlist.go lists it
picker.go the list modal; picker_open.go opens it
theme.go + palette.go / styles.go
internal/gittest throwaway git repositories for tests, in one place because
four packages had grown their own and they had drifted
probe/ debug harness: renders frames without a human present
```

Expand Down Expand Up @@ -345,6 +350,47 @@ with anything else lying around in the tree.
later, for the reason in (1): by the time anyone asks, the branch it came from
has moved.

### A spawned review (`coord.Analyse`, `agent.RunClaude`)

`analyse` starts a **separate agent** to review a sibling's work. Four
decisions shape it, and each was reached by measuring rather than by argument.

**Asynchronous, because the timeout is not ours.** A synchronous tool call
blocks the *caller's* tool timeout, which belongs to the calling agent rather
than to Deck — `mcp.go` sets only a `ReadHeaderTimeout` on request headers, so
nothing here would cut a long review off. A review that outruns the caller
returns nothing and has already spent the money. `Analyse` hands back a handle
and `Analysis` collects it.

**The reviewer is handed its evidence and given nothing else.** It receives the
diff in its prompt and no coordination config, because it has nothing to ask
anyone; wiring it to this server would be surface with no caller. It runs under
a permission mode that answers a question but refuses to act, so a review
cannot become an edit. Everything it needs must therefore be in the prompt,
which is what `TestTheQuestionReachesTheReviewer` pins.

**A failed turn is a result, not an error.** `agent.ClaudeRun` carries
`Failure` as a field rather than returning a Go error, because a turn that ran
and refused still spent money and Go's convention tells a caller to discard the
value alongside an error. That would put the bill out of reach in the one case
where it is surprising. An `error` from `RunClaude` means the opposite: no
envelope came back, so there is no accounting to add and inventing a figure
would be worse than the gap. Do not "tidy" this into a plain error return.

**Cost is per session, and never written to disk.** A per-run figure alone is
hard to read — the same short turn was measured at $0.012 and $0.237 depending
on whether its context was read from cache or written to it — so the running
total is what makes a pattern visible. It is dropped with the session, like
claims and the inbox, because a review belongs to the session that paid for it.

Jobs are bounded like the inbox and the log. Dropping the oldest is safe:
`Spend` is a running total kept separately, so a discarded record costs the
reader an old answer and never the bill.

`Close` cancels every run the coordinator started. Without that, quitting Deck
mid-review left an agent running and billing with no surface left to show it
on, which is the opposite of what the cost reporting exists for.

### The store is global, not per-directory (`main.registerCwd`)

One `state.json` holds every project and session, so all of them are reachable
Expand Down
132 changes: 132 additions & 0 deletions internal/agent/headless.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package agent

// A headless agent run: one turn, no pseudo-terminal, and a structured report
// of what it cost. Used for work Deck starts on an agent's behalf rather than
// on a person's, where nobody is watching a pane.

import (
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strings"
"time"
)

// Tokens is the usage a run reported, split the way billing splits it.
//
// Cache writes and reads are separated because they price differently and are
// the largest single influence on what a short run costs: the same one-word
// reply measured at $0.012 when its context was read from cache and $0.237
// when the same context was written to it.
type Tokens struct {
Input int `json:"input_tokens"`
Output int `json:"output_tokens"`
CacheRead int `json:"cache_read_input_tokens"`
CacheWrite int `json:"cache_creation_input_tokens"`
}

// ClaudeRun is what one headless turn reported.
//
// A turn that ran and failed is a result, not an error: it has a cost, and
// that cost is the one most worth noticing. So Failure is a field rather than
// a returned error. RunClaude returns an error only when there is no
// accounting at all — the process would not start, or produced nothing we can
// read — because Go's convention tells a caller to discard the value alongside
// an error, which would put the bill out of reach exactly when it is
// surprising.
type ClaudeRun struct {
Text string // the model's final answer, empty when it failed
Failure string // why the turn produced no answer; empty on success
CostUSD float64 // what the turn cost, whether or not it answered
Tokens Tokens // usage, split by kind
Took time.Duration // how long the turn took, as the CLI measured it
}

// Failed reports whether the turn ran without producing an answer.
func (r ClaudeRun) Failed() bool { return r.Failure != "" }

// resultEnvelope is the one event in the stream that carries the totals. The
// field names are claude's; another agent would need its own parser, which is
// why this function is named for the one it understands.
type resultEnvelope struct {
Type string `json:"type"`
Subtype string `json:"subtype"`
IsError bool `json:"is_error"`
Result string `json:"result"`
DurationMS int `json:"duration_ms"`
CostUSD float64 `json:"total_cost_usd"`
Usage Tokens `json:"usage"`
}

// RunClaude runs one non-interactive turn in dir and reports what it cost,
// with the answer when there is one.
//
// A turn that ends in a refusal or an API error comes back as a ClaudeRun with
// Failure set and its cost intact, not as an error. See ClaudeRun.
//
// The prompt goes over stdin rather than as an argument: with stdin empty
// claude reports "input must be provided" and ignores a positional prompt.
//
// The environment is scrubbed exactly as an interactive session's is, so a
// spawned run cannot inherit credentials or the child-session marker from
// whatever started Deck.
func RunClaude(ctx context.Context, dir, prompt string, args ...string) (ClaudeRun, error) {
full := append([]string{"-p", "--output-format", "json"}, args...)
cmd := exec.CommandContext(ctx, "claude", full...)
cmd.Dir = dir
cmd.Env = ScrubbedEnv()
cmd.Stdin = strings.NewReader(prompt)

out, err := cmd.Output()
if err != nil {
// The CLI reports its own diagnosis on stderr; a bare "exit status 1"
// tells the caller nothing about whether it was auth, a bad flag or a
// refusal.
var ee *exec.ExitError
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
return ClaudeRun{}, fmt.Errorf("claude: %s", strings.TrimSpace(string(ee.Stderr)))
}
return ClaudeRun{}, fmt.Errorf("claude: %w", err)
}
return parseClaudeJSON(out)
}

// parseClaudeJSON pulls the totals out of a --output-format json stream.
//
// The stream is an array of events, not a single object: an init event, the
// assistant turns, and one result event carrying the totals. Reading the last
// element would work today and break the moment anything is appended after
// it, so this selects by type.
func parseClaudeJSON(out []byte) (ClaudeRun, error) {
var events []resultEnvelope
if err := json.Unmarshal(out, &events); err != nil {
// A single object rather than an array is also valid JSON output; try
// it before giving up, so a change of shape degrades to one parse
// failure rather than to a wrong answer.
var one resultEnvelope
if json.Unmarshal(out, &one) != nil {
return ClaudeRun{}, fmt.Errorf("claude produced no readable result: %w", err)
}
events = []resultEnvelope{one}
}
for _, e := range events {
if e.Type != "result" {
continue
}
run := ClaudeRun{
Text: e.Result,
CostUSD: e.CostUSD,
Tokens: e.Usage,
Took: time.Duration(e.DurationMS) * time.Millisecond,
}
if e.IsError {
run.Text = ""
run.Failure = strings.TrimSpace(e.Subtype + ": " + e.Result)
}
return run, nil
}
// No result event means no accounting: whatever it spent, we cannot say.
return ClaudeRun{}, fmt.Errorf("claude produced no result event")
}
96 changes: 96 additions & 0 deletions internal/agent/headless_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package agent

import (
"strings"
"testing"
"time"
)

// A captured stream, trimmed to the events that matter. The shape is the one
// `claude -p --output-format json` actually emits: an array, with the totals
// in a result event rather than in the last element by position.
const captured = `[
{"type":"system","subtype":"init","session_id":"abc"},
{"type":"assistant","message":{"role":"assistant"}},
{"type":"result","subtype":"success","is_error":false,"result":"ZEPHYR_QUOTA_GUARD",
"duration_ms":1624,"num_turns":1,"total_cost_usd":0.23709,
"usage":{"input_tokens":2,"output_tokens":4,
"cache_read_input_tokens":0,"cache_creation_input_tokens":23698}},
{"type":"trailing_event_added_later"}
]`

// TestParseClaudeJSONSelectsByType is why the parser does not read the last
// element. A stream with anything appended after the result would otherwise
// report zero cost and no answer.
func TestParseClaudeJSONSelectsByType(t *testing.T) {
run, err := parseClaudeJSON([]byte(captured))
if err != nil {
t.Fatal(err)
}
if run.Text != "ZEPHYR_QUOTA_GUARD" {
t.Errorf("text = %q", run.Text)
}
if run.CostUSD != 0.23709 {
t.Errorf("cost = %v, want 0.23709", run.CostUSD)
}
if run.Took != 1624*time.Millisecond {
t.Errorf("took = %v, want the duration the CLI reported", run.Took)
}
}

// TestParseClaudeJSONSplitsCacheTokens covers the split that explains a bill.
// Reads and writes of the same context price differently, so collapsing them
// into one number would hide the largest influence on a short run's cost.
func TestParseClaudeJSONSplitsCacheTokens(t *testing.T) {
run, err := parseClaudeJSON([]byte(captured))
if err != nil {
t.Fatal(err)
}
want := Tokens{Input: 2, Output: 4, CacheRead: 0, CacheWrite: 23698}
if run.Tokens != want {
t.Errorf("tokens = %+v, want %+v", run.Tokens, want)
}
}

// TestFailedTurnKeepsItsCost is the property the whole shape exists for. A
// turn that ran and refused still spent money, and returning it as a Go error
// would tell every caller to discard the value — putting the bill out of reach
// in the one case where it is surprising.
func TestFailedTurnKeepsItsCost(t *testing.T) {
stream := `[{"type":"result","subtype":"error_max_turns","is_error":true,
"result":"ran out of turns","total_cost_usd":0.4,
"usage":{"input_tokens":7,"output_tokens":0}}]`
run, err := parseClaudeJSON([]byte(stream))
if err != nil {
t.Fatalf("a turn that ran was reported as unusable: %v", err)
}
if !run.Failed() {
t.Error("a refused turn does not report itself as failed")
}
if run.CostUSD != 0.4 {
t.Errorf("cost = %v, want 0.4 — a failed run that spent was not counted", run.CostUSD)
}
if run.Tokens.Input != 7 {
t.Errorf("tokens = %+v, want the usage the envelope reported", run.Tokens)
}
if run.Text != "" {
t.Errorf("text = %q; a failed turn has no answer to give", run.Text)
}
for _, want := range []string{"error_max_turns", "ran out of turns"} {
if !strings.Contains(run.Failure, want) {
t.Errorf("failure %q does not mention %q", run.Failure, want)
}
}
}

// TestParseClaudeJSONReportsAMissingResult covers a stream that ends without
// totals — a crash mid-run. Returning a zero-cost success would under-report
// the bill and hand the caller an empty answer as if it were real.
func TestParseClaudeJSONReportsAMissingResult(t *testing.T) {
if _, err := parseClaudeJSON([]byte(`[{"type":"system","subtype":"init"}]`)); err == nil {
t.Error("a stream with no result event parsed as a success")
}
if _, err := parseClaudeJSON([]byte(`not json at all`)); err == nil {
t.Error("unparseable output was accepted")
}
}
Loading
Loading