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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Declared in TOML or YAML manifests, applied with `marvel work`.
|---|---|---|
| Namespace | **Workspace** | Isolation boundary: a project, team, or environment. Scopes every other resource. |
| Pod | **Session** | The atomic unit. One tmux pane running one harness process. Lifecycle pending → running → succeeded/failed, plus crashed and crashloop-backoff. Restartable. |
| Container | **Runtime** | The harness binary, args, and mode, plus `context_window` to override the model-to-window table for CTX%. `runtime` names the HARNESS (claude, codex, opencode), never the agent: `elem-runtime-names-harness`. |
| Container | **Runtime** | The harness binary, args, and mode, plus `context_window` to override the model-to-window table for CTX%, and `context_feed = "statusline"` to give interactive claude sessions a cooperative CTX% feed via projected statusline hooks + `marvel ctx-forward` (finding-011, `examples/context-feed.toml`). `runtime` names the HARNESS (claude, codex, opencode), never the agent: `elem-runtime-names-harness`. |
| Deployment | **Team** | Heterogeneous roles, each with its own runtime and replica count. Per-role scaling, shifts. Binds a supervisor to its agents. |
| (none) | **Role** | One kind of agent within a team: name, replicas, runtime, restart policy, `max_restarts`, `permissions`, `dangerous_permissions`, `policy`, persona, identity. |
| Service | **Endpoint** | A named record of `{name, workspace, team}` and nothing else. Created from a manifest `[[endpoint]]` section, read with `marvel get endpoints` and `marvel describe endpoint`. No role field, and no code resolves an endpoint to a session, so it is a name in the store rather than a routing target. Role-based routing waits on director (roadmap M2). |
Expand Down
130 changes: 130 additions & 0 deletions cmd/marvel/ctxforward.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package main

import (
"encoding/json"
"fmt"
"io"
"os"

"github.com/arcavenae/marvel/internal/daemon"
"github.com/spf13/cobra"
)

// ctx-forward is the statusline side of the cooperative context feed
// (finding-011). Claude Code invokes the configured statusline command
// with a JSON payload on stdin; the projection layer points that hook at
// this subcommand. It forwards the harness's own context figure to the
// daemon's heartbeat RPC and prints a compact status string for the
// human attached to the pane.
//
// Two payload shapes arrive here, distinguished by their keys:
// - the main statusLine payload (context_window object, cost object)
// - the subagentStatusLine payload (tasks array)
//
// Failure posture: this runs on every statusline tick inside an agent's
// pane, so it never exits nonzero and never prints errors — a broken
// feed shows as a silent gap in CTX%, diagnosed via `marvel describe
// session`, not as red text inside the agent's terminal.

// statuslinePayload is the subset of Claude Code's statusline JSON that
// the forwarder reads. Fields the forwarder does not use are omitted —
// the harness owns this schema, marvel just picks out context and cost.
type statuslinePayload struct {
Model struct {
DisplayName string `json:"display_name"`
} `json:"model"`
Cost struct {
TotalCostUSD float64 `json:"total_cost_usd"`
} `json:"cost"`
ContextWindow *struct {
UsedPercentage *float64 `json:"used_percentage"`
} `json:"context_window"`
Tasks []struct {
Status string `json:"status"`
TokenCount int `json:"tokenCount"`
ContextWindowSize int `json:"contextWindowSize"`
} `json:"tasks"`
}

// renderForward parses one statusline payload and returns the status text
// to print plus the context percentage to forward (send=false when the
// payload carries no forwardable figure). Pure so it is table-testable.
func renderForward(raw []byte) (line string, pct float64, send bool) {
var p statuslinePayload
if err := json.Unmarshal(raw, &p); err != nil {
return "marvel ctx-forward: unreadable payload", 0, false
}

// Subagent shape: summarize the task rows. No RPC — the daemon has
// no per-subagent surface yet (tracked in aae-orc-7hzb).
if len(p.Tasks) > 0 {
running := 0
maxPct := 0.0
for _, t := range p.Tasks {
if t.Status == "running" {
running++
}
if t.ContextWindowSize > 0 {
if pc := float64(t.TokenCount) / float64(t.ContextWindowSize) * 100; pc > maxPct {
maxPct = pc
}
}
}
return fmt.Sprintf("agents %d/%d running · max CTX %.0f%%", running, len(p.Tasks), maxPct), 0, false
}

if p.ContextWindow == nil || p.ContextWindow.UsedPercentage == nil {
// Session too young to have a measurement. Show something
// stable rather than flickering an error.
return fmt.Sprintf("%s · CTX –", orUnknown(p.Model.DisplayName)), 0, false
}
pct = *p.ContextWindow.UsedPercentage
line = fmt.Sprintf("%s · CTX %.0f%% · $%.2f", orUnknown(p.Model.DisplayName), pct, p.Cost.TotalCostUSD)
return line, pct, true
}

func orUnknown(s string) string {
if s == "" {
return "agent"
}
return s
}

func newCtxForwardCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "ctx-forward",
Short: "Forward a statusline payload's context figure to the daemon (internal)",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
raw, err := readAllStdin()
if err != nil {
fmt.Println("marvel ctx-forward")
return nil
}
line, pct, send := renderForward(raw)
fmt.Println(line)

socket := os.Getenv("MARVEL_SOCKET")
workspace := os.Getenv("MARVEL_WORKSPACE")
session := os.Getenv("MARVEL_SESSION")
if !send || socket == "" || workspace == "" || session == "" {
return nil
}
params, _ := json.Marshal(map[string]any{
"session_key": workspace + "/" + session,
"context_percent": pct,
})
// Best-effort by design; see the failure posture above.
_, _ = daemon.SendRequest(socket, daemon.Request{
Method: "heartbeat",
Params: params,
})
return nil
},
}
return cmd
}

func readAllStdin() ([]byte, error) {
return io.ReadAll(os.Stdin)
}
70 changes: 70 additions & 0 deletions cmd/marvel/ctxforward_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package main

import (
"strings"
"testing"
)

func TestRenderForward(t *testing.T) {
t.Parallel()
tests := []struct {
name string
payload string
wantSend bool
wantPct float64
wantIn string
}{
{
name: "main payload with measurement",
payload: `{"model":{"display_name":"Haiku 4.5"},
"cost":{"total_cost_usd":0.0898},
"context_window":{"used_percentage":17}}`,
wantSend: true,
wantPct: 17,
wantIn: "CTX 17%",
},
{
name: "young session, null percentage",
payload: `{"model":{"display_name":"Haiku 4.5"},
"cost":{"total_cost_usd":0},
"context_window":{"used_percentage":null}}`,
wantSend: false,
wantIn: "CTX –",
},
{
name: "no context_window at all",
payload: `{"model":{"display_name":"Haiku 4.5"}}`,
wantSend: false,
wantIn: "CTX –",
},
{
name: "subagent payload never sends",
payload: `{"tasks":[
{"status":"running","tokenCount":11238,"contextWindowSize":200000},
{"status":"completed","tokenCount":10818,"contextWindowSize":200000}]}`,
wantSend: false,
wantIn: "agents 1/2 running",
},
{
name: "garbage payload",
payload: `not json`,
wantSend: false,
wantIn: "unreadable",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
line, pct, send := renderForward([]byte(tt.payload))
if send != tt.wantSend {
t.Fatalf("send = %v, want %v", send, tt.wantSend)
}
if send && pct != tt.wantPct {
t.Errorf("pct = %v, want %v", pct, tt.wantPct)
}
if !strings.Contains(line, tt.wantIn) {
t.Errorf("line = %q, want it to contain %q", line, tt.wantIn)
}
})
}
}
1 change: 1 addition & 0 deletions cmd/marvel/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ func main() {
root.AddCommand(configCmd())
root.AddCommand(stopCmd())
root.AddCommand(eventsCmd())
root.AddCommand(newCtxForwardCmd())

if err := root.Execute(); err != nil {
os.Exit(1)
Expand Down
41 changes: 41 additions & 0 deletions examples/context-feed.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Marvel example — cooperative context feed for interactive Claude Code.
#
# Interactive (TUI) sessions emit no parseable stream, so the usage
# accountant cannot meter them and CTX% renders "-". context_feed =
# "statusline" closes that gap: the projection layer injects Claude
# Code's statusLine/subagentStatusLine hooks pointing at
# `marvel ctx-forward`, which forwards the harness's OWN context figure
# (raw occupancy, with the harness's own window as denominator) to the
# heartbeat RPC. See marvel finding-011.
#
# The injected statusLine carries refreshInterval = 15 so the feed keeps
# beating while the session idles; without it, statusline updates are
# event-driven and an idle session would starve a heartbeat healthcheck.
#
# Composition rule: if the role's policy defines statusLine or
# subagentStatusLine itself, the policy wins and marvel adds nothing.
#
# Setup:
# just build
# ./bin/marvel daemon &
# ./bin/marvel work examples/context-feed.toml
#
# Verify (after the first model turn):
# ./bin/marvel get sessions # CTX% populates for the watcher
# ./bin/marvel describe session feed/watch-watcher-g1-0

[workspace]
name = "feed"

[[team]]
name = "watch"

[[team.role]]
name = "watcher"
replicas = 1
permissions = "plan"

[team.role.runtime]
image = "claude"
command = "claude"
context_feed = "statusline"
9 changes: 9 additions & 0 deletions internal/api/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ type ManifestRuntime struct {
Prompt string `toml:"prompt,omitempty" yaml:"prompt,omitempty"`
// ContextWindow overrides the model-to-limit table, in tokens.
ContextWindow int `toml:"context_window,omitempty" yaml:"context_window,omitempty"`
// ContextFeed opts an interactive session into cooperative context
// reporting. Only "statusline" is understood. See api.Runtime.
ContextFeed string `toml:"context_feed,omitempty" yaml:"context_feed,omitempty"`
}

// ManifestEndpoint is an endpoint section of a manifest.
Expand Down Expand Up @@ -274,6 +277,11 @@ func validateManifest(m *Manifest) (*Manifest, error) {
if r.Runtime.ContextWindow < 0 {
return nil, fmt.Errorf("parse manifest: team[%d].role[%d].runtime.context_window must be >= 0", i, j)
}
// Like permissions: empty means unset, but a non-empty typo
// would silently project nothing, so reject it here.
if r.Runtime.ContextFeed != "" && r.Runtime.ContextFeed != ContextFeedStatusline {
return nil, fmt.Errorf("parse manifest: team[%d].role[%d].runtime.context_feed %q is not valid (valid: %q)", i, j, r.Runtime.ContextFeed, ContextFeedStatusline)
}
if r.Policy != "" && !policyNames[r.Policy] {
return nil, fmt.Errorf("parse manifest: team[%d].role[%d] references undefined policy %q", i, j, r.Policy)
}
Expand Down Expand Up @@ -524,6 +532,7 @@ func (m *Manifest) Apply(store *Store) error {
Mode: mr.Runtime.Mode,
Prompt: mr.Runtime.Prompt,
ContextWindow: mr.Runtime.ContextWindow,
ContextFeed: mr.Runtime.ContextFeed,
}
if rt.Name == "" {
rt.Name = rt.Command
Expand Down
11 changes: 11 additions & 0 deletions internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,19 @@ type Runtime struct {
// window the harness declares itself outranks this, because the
// harness's own belief is what enforces compaction.
ContextWindow int `toml:"context_window,omitempty"`
// ContextFeed opts an interactive session into a cooperative context
// pressure feed. The only value today is "statusline": the projection
// layer injects statusLine/subagentStatusLine hooks pointing at
// `marvel ctx-forward`, which forwards the harness's own context
// figures to the heartbeat RPC. Headless sessions do not need this —
// their stream already feeds the usage accountant. See finding-011.
ContextFeed string `toml:"context_feed,omitempty"`
}

// ContextFeedStatusline is the only ContextFeed value marvel understands
// today: statusline-hook forwarding for interactive claude sessions.
const ContextFeedStatusline = "statusline"

// Session is the atomic unit: a tmux pane running one process (pod equivalent).
//
// PID is tmux's pane_pid: the shell tmux started, not the agent binary
Expand Down
Loading