diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 267f686..0361052 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,10 @@ concurrency: jobs: verify: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [22, 24] steps: - uses: actions/checkout@v4 @@ -21,7 +25,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: ${{ matrix.node-version }} cache: pnpm - name: install deps diff --git a/README.md b/README.md index c73c70b..afd0d62 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ strand init # first-run wizard — pick provider, store key strand doctor # preflight health check strand run "summarize the README and commit a rewrite" # one-shot agentic plan strand tui # welcome splash · [d] live dashboard +strand cockpit # live operator cockpit for a pinned terminal strand status # orchestrator + reasoner/consolidator summary strand tasks list # persisted TaskGraphs strand tasks show # graph + steps + reflections diff --git a/config/policies.yaml b/config/policies.yaml index eb4ac29..db24413 100644 --- a/config/policies.yaml +++ b/config/policies.yaml @@ -1,8 +1,10 @@ # Must match PoliciesConfigSchema in src/config.ts. # Values here are the *ceiling*; `effectiveCap` multiplies by ramp_multiplier. +# Phase 3 configuration: like/bookmark live, all other actions shadow +# ramp_multiplier 0.5 = half-caps during ramp-up (100 likes/day, 15 bookmarks/day) mode: shadow -ramp_multiplier: 0.25 +ramp_multiplier: 0.5 caps_per_day: posts: 6 diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index cd8a1f4..ca8b87d 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -159,11 +159,132 @@ can attribute label quality to a specific prompt version. Before enabling low-risk live actions (`like` + `bookmark`): +- [ ] `pnpm strand review gate-check` exits 0 (≥100 labeled, ≥80% agreement) - [ ] `pnpm strand review agreement --json | jq '.gate.met'` → `true` - (≥100 labeled candidates AND ≥80% agreement in `mode=shadow`) + (confirms same criteria with full confusion matrix) - [ ] Confusion matrix shows no systematic `false_approve` bias (i.e. policy approves ≤5 actions the operator would reject) - [ ] No `reasoner.candidate_cap_enforced` warnings sustained over ≥48h (model consistently emits ≤5 — overrun implies prompt drift) - [ ] Actor dry-run verified: `action_log` rows show `status='executed'` in shadow mode with the write path short-circuited before the X API call + +## Phase 3: Low-risk actions live + +In Phase 3 the Actor enables **only** `like` and `bookmark` in live mode. All +other actions (`reply`, `quote`, `post`, `follow`, `dm`) remain in shadow mode +even when `STRAND_MODE=live`. + +### Phase 3 gate checklist + +Before enabling live actions: + +```bash +# 1. Verify gate criteria met (≥100 labeled, ≥80% agreement) +pnpm strand review gate-check + +# 2. Verify half-caps configured (ramp_multiplier should be 0.5) +cat config/policies.yaml | grep ramp_multiplier # should be 0.5 + +# 3. Check metrics baseline (run for 24h in shadow with metrics enabled) +pnpm strand status --metrics +``` + +### Enabling live mode + +```bash +# 1. Confirm readiness +pnpm strand review gate-check --json | jq '.ready' # should be true + +# 2. Set live mode (only like/bookmark will actually go live) +export STRAND_MODE=live +export STRAND_HALT=false + +# 3. Restart orchestrator +pkill -SIGTERM -f "strand start" +pnpm strand start & + +# 4. Record transition +echo "Phase 3 live start: $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> ./data/phase3.log +``` + +### Phase 3 kill switch (drain mode) + +If anything goes wrong, the kill switch implements drain semantics: + +```bash +# STRAND_HALT stops the Reasoner (no new candidates) +# Perceiver continues (reads are safe) +# In-flight actions complete; new actions are rejected +export STRAND_HALT=true +``` + +Verify drain state: +```bash +# Check halt is active +pnpm strand status --json | jq '.env.strand_halt' # should be "true" + +# Check no new candidates being emitted (reasoner_runs should stop growing) +pnpm strand status | grep reasoner_runs +``` + +### Monitoring Phase 3 health + +Check metrics dashboard every 4 hours: + +```bash +# Full metrics dashboard +pnpm strand status --metrics + +# Key metrics to watch: +# - X API health: rate limits healthy, monthly cap < 50% +# - Follower delta: no sudden negative spikes (>10% drop) +# - Error rates: < 5% failure rate on like/bookmark +``` + +### Cap enforcement (half-caps during ramp-up) + +Phase 3 uses `ramp_multiplier: 0.5` in `policies.yaml`: + +| Action | Daily Cap (full) | Phase 3 Cap (0.5x) | +|--------|------------------|-------------------| +| likes | 200 | 100 | +| bookmarks | 50 | 25 | + +Verify caps in effect: +```bash +# Check action_log for cap enforcement +sqlite3 ./data/strand.db "SELECT kind, COUNT(*) FROM action_log WHERE status='executed' AND created_at > datetime('now', '-24 hours') GROUP BY kind" +``` + +### Rollback to shadow + +If you need to revert: + +```bash +# 1. Halt first (drain in-flight) +export STRAND_HALT=true +sleep 30 # wait for drain + +# 2. Switch back to shadow +export STRAND_MODE=shadow +export STRAND_HALT=false + +# 3. Restart +pkill -SIGTERM -f "strand start" +pnpm strand start & + +# 4. Record rollback +echo "Phase 3 rollback: $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> ./data/phase3.log +``` + +### Gate to Phase 4 + +Before enabling `reply` live: + +- [ ] Phase 3 ran clean for ≥ 72 hours +- [ ] `like` and `bookmark` error rate < 1% +- [ ] No X rate limit 429s sustained +- [ ] Follower delta stable (no negative trend) +- [ ] `pnpm strand review agreement --mode=live` shows ≥90% agreement +- [ ] Human review queue shows manageable volume diff --git a/docs/superpowers/specs/2026-04-24-strand-cockpit-design.md b/docs/superpowers/specs/2026-04-24-strand-cockpit-design.md new file mode 100644 index 0000000..170e180 --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-strand-cockpit-design.md @@ -0,0 +1,434 @@ +# Strand Cockpit Redesign — Design Spec + +**Date:** 2026-04-24 +**Author:** Terrence (via Claude Opus 4.7 brainstorming) +**Status:** Approved for implementation plan +**Audience:** Codex (team lead), 4 Devin agents, 1 Claude Code agent + +--- + +## Executive summary + +Replace the current gamified Strand cockpit with a chat-first, provider-agnostic agent harness. The existing X/Twitter engine (Perceiver / Reasoner / Actor / Consolidator) keeps running as a registered background loop; the cockpit stops being a Twitter monitor and becomes a generic operator chat interface with pluggable LLM providers, multi-backend subagent spawning, and a self-curating skill lifecycle. + +Architecture reference: [NousResearch/hermes-agent](https://github.com/NousResearch/hermes-agent). Where hermes has solved the same problem cleanly, Strand copies the pattern verbatim and cites it. + +--- + +## Cross-cutting principle: LEAN BY DEFAULT + +Every token in Strand's own runtime costs the operator money. The cockpit is the operator's *interface to agents*, not an agent itself — it should add the smallest possible context footprint on top of the user's prompt. + +**Enforced everywhere:** +- No skill bodies in the system prompt. Skills are retrieved JIT (top-K=3 by default, configurable down to 0). +- No background-loop telemetry in chat context. Systems drawer is off by default. +- Subagents default to `--bare` + minimal `--allowedTools` when the auth mode permits (see hard constraint #7). +- Reflexion judge defaults to the cheapest capable model (Haiku / GPT-4o-mini / Grok-4-fast). Never the reasoner. +- Context compaction default flips from `noop` → `summarizing` (`thresholdRatio: 0.75`, `keepTailTurns: 8`, `summarizerMaxOutputTokens: 800`). +- Event schema is lean — chunks are byte-sized, no giant base64 payloads through the renderer protocol. +- Every provider call logs `usage.{input,cached,output}_tokens` + prompt_cache_key. Unused cache = bug. + +If a feature adds context weight to the main chat without a direct operator-visible benefit, it's wrong by default. + +--- + +## Hard constraints (non-negotiable) + +These are contracts every stream owner tests against, not prose to read once. + +1. **Policy-gate preservation.** Any chat-driven action that maps to an X/Twitter action kind MUST still flow through the existing `Candidate` typestate gate in `src/policy/index.ts`. Subagents propose `Candidate`; only the gate mints `Approved`. Enforced at compile time — TS should refuse a bypass path. Property tests in S1. + +2. **Renderer protocol is pinned in §4 of this spec.** Breaking changes bump the `X-Cockpit-Protocol` header major version. Ink and Web renderers consume the identical schema. Schema drift = P0 bug. + +3. **`oauth_external` credential reuse is local-only; `oauth_device_code` works anywhere.** BYOK works anywhere. The auth picker tells the user which modes are available based on whether the cockpit is running on the same machine as their logged-in `claude` / `codex` / `gemini` CLI. + +4. **Anthropic "OAuth-external" mode carries a billing warning.** Per open hermes-agent issue #12905, Anthropic routes third-party OAuth clients to the `extra_usage` billing pool, which is empty for most users. The cockpit surfaces this inline before the first call. No silent fallback fiction. + +5. **No implicit activation from environment variables.** The presence of `CLAUDE_CODE_OAUTH_TOKEN` in the environment does NOT auto-activate the Anthropic provider. The user must explicitly pick a provider in the first-run flow or via `/auth`. Prevents silent token spend. + +6. **Skill retirement is queued, not silent.** v1 ships with auto-retire proposals landing in a review feed; user approves with one click. Flip to silent after usage data validates the signal. + +7. **Claude Code handling contract (`--bare` gotcha).** Bare mode skips OAuth and requires `ANTHROPIC_API_KEY`. The `cli-process` backend's Claude Code parser never passes `--bare` when the user's auth mode is `oauth_external`. In BYOK-Anthropic mode, `--bare` is the **default** for subagent spawns (fastest startup, lowest token overhead) — operator can opt in to full-context mode per-spawn. + +8. **Lean budget defaults.** Default budgets per cockpit session: `tokens: 50_000`, `usdTicks: 2_000_000` ($0.002), `wallClockMs: 300_000`, `toolCalls: 40`. Subagent spawns get half their parent's remaining budget by default. Operator can raise per-session; the default is set to yell early on bloat. + +--- + +## §1 — Architecture at a glance + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Cockpit Core (headless, no UI imports) │ +│ ┌───────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ Transcript │ │ Subagent │ │ Skill Lifecycle │ │ +│ │ event bus │ │ registry │ │ (c + iii) │ │ +│ └───────────────┘ └──────────────┘ └──────────────────┘ │ +│ ┌───────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ Provider │ │ Policy gate │ │ Loop registry │ │ +│ │ router │ │ (untouched) │ │ (X engine = │ │ +│ │ │ │ │ │ one entry) │ │ +│ └───────────────┘ └──────────────┘ └──────────────────┘ │ +└──────────┬──────────────────────┬───────────────────────────┘ + │ Renderer Protocol │ Renderer Protocol + │ (pinned SSE schema) │ + ▼ ▼ + ┌──────────────┐ ┌──────────────┐ + │ Ink renderer │ │ Web renderer │ + │ (terminal) │ │ (Vite+Hono) │ + └──────────────┘ └──────────────┘ +``` + +**Two invariants:** +- Core never imports from either renderer. +- Both renderers consume the same event schema. + +--- + +## §2 — Provider / subagent / skill split + +This is the structural refactor that everything else depends on. The hermes codebase demonstrates the split; Strand adopts it. + +| Layer | What it is | Examples | +|---|---|---| +| **Provider** | Chat completions source — where tokens come from | `anthropic-api`, `openai-api`, `xai-api`, `gemini-api`, `openai-compat` (Ollama / LM Studio / OpenRouter / Together) | +| **Subagent** | Delegatable worker the main agent spawns | `internal`, `cli-process` (generic), `ssh` | +| **Skill** | Markdown instruction telling the agent *when* to use a provider / tool / subagent | `claude-code.md`, `codex.md`, `pr-review.md`, arbitrary new skills | + +**Consequences:** +- `claude` and `codex` CLIs are NOT LLM providers. They are skills that invoke the `cli-process` subagent backend. One generic backend, unlimited CLI skills. +- Adding a new CLI (Aider, Cline, gpt-engineer, whatever ships next) = write a skill, not a backend. +- The existing `src/clients/llm/` stays the home for provider adapters. Subagents live in `src/agent/`. + +--- + +## §3 — Auth & provider model + +Reference implementation: `hermes_cli/auth.py`. + +### Auth types + +| Auth type | Mechanism | Host constraint | +|---|---|---| +| `api_key` | User-supplied key, read from env or Strand's encrypted store | Any | +| `oauth_device_code` | Real PKCE device-code flow. POST to issuer's `/deviceauth/usercode`, show user a URL + code, poll, exchange at `/oauth/token`. Strand manages refresh. | Any | +| `oauth_external` | Read credentials another tool wrote to disk (`~/.claude/.credentials.json`, `~/.qwen/oauth_creds.json`, etc.) | Local only | + +### Per-provider plan + +**Lean list.** v1 ships with exactly these providers. No Kimi / z.ai / MiniMax / DeepSeek / etc. in the first cut — the `openai-compat` entry already covers any OpenAI-API-compatible endpoint via `baseURL`, which handles 90% of future additions without new adapter code. + +| Provider | Primary | Secondary | Notes | +|---|---|---|---| +| Anthropic | `api_key` (`ANTHROPIC_API_KEY`) | `oauth_external` from Claude Code creds | Secondary shows billing warning (hard constraint #4) | +| OpenAI | `api_key` (`OPENAI_API_KEY`) | `oauth_device_code` against `auth.openai.com` (genuine PKCE — see hermes `_codex_device_code_login`) | Device-code works on any host | +| xAI | `api_key` (`XAI_API_KEY`) | — | Removed as the default — user picks | +| Gemini | `api_key` (`GEMINI_API_KEY`) | `oauth_external` from gemini-cli creds | | +| openai-compat | `api_key` + `baseURL` | — | Catches Ollama, LM Studio, OpenRouter, Together, and the long tail — no per-vendor adapter | + +### Device-code flow reference (OpenAI) + +``` +POST https://auth.openai.com/api/accounts/deviceauth/usercode + body: { client_id } + → { user_code, device_auth_id, interval } + +# Show user: open https://auth.openai.com/codex/device, enter code +# Poll: +POST https://auth.openai.com/api/accounts/deviceauth/token + body: { device_auth_id, user_code } + → 200 { authorization_code, code_verifier } OR 403/404 (not yet) + +POST https://auth.openai.com/oauth/token + body (form): { grant_type: authorization_code, code, redirect_uri, + client_id, code_verifier } + → { access_token, refresh_token, id_token, expires_in } +``` + +Max wait 15 minutes. Poll interval ≥ 3s. Port hermes's implementation directly. + +### Auth store shape + +```jsonc +// ~/.strand/auth.json +{ + "active_provider": "openai", + "providers": { + "openai": { "auth_type": "oauth_device_code", "tokens": {...}, "expires_at": "..." }, + "anthropic": { "auth_type": "api_key", "source": "env:ANTHROPIC_API_KEY" } + }, + "suppressed_sources": { "anthropic": ["cli_credentials"] } +} +``` + +**Rules** (verbatim from hermes): +1. No implicit use of external credentials — see hard constraint #5. +2. `suppressed_sources` lets users blacklist a specific discovery path per provider. +3. Single-writer file lock on the auth store during refresh. + +### First-run UX + +No default. Picker shows the provider table with inline "how this will be billed" copy. Choice persists to `~/.strand/auth.json` + `~/.strand/profile.json`. Switching is a slash command: `/model anthropic claude-sonnet-4-6`. + +**`strand.config.yaml`:** the `llm.provider: xai` default is removed. Explicit selection required or cockpit errors with a clear picker prompt. + +### Language in the UI + +Label the `oauth_external` entries honestly: *"Use my Claude Pro/Max subscription (local only) — may bill as metered API usage, see notice"*. Avoid the word "OAuth" alone, since the semantics vary per provider. + +--- + +## §4 — Cockpit substrate + +### Packages + +``` +src/cockpit/core/ ← no UI imports; pure TypeScript +src/cockpit/ink/ ← depends on core only +src/cockpit/web/ ← depends on core only; Vite + Hono, served by `strand dev` +``` + +### Core exports + +- `Transcript` — append-only event log (SQLite-backed, keyed by session UUID). Survives restarts. +- `ChatController` — takes user input, routes to provider, emits events. +- `SubagentRegistry` — tracks spawned workers (see §5). +- `SkillRegistry` — see §6. +- `ProviderRouter` — picks the right provider per the auth/profile from §3. +- `EventBus` — in-process `EventEmitter`; renderers subscribe. + +### Renderer protocol (PINNED) + +```ts +type CockpitEvent = + | { t: 'transcript.append', sessionId: string, message: Message } + | { t: 'transcript.delta', sessionId: string, messageId: string, chunk: string } + | { t: 'tool.start', sessionId: string, callId: string, name: string, args: unknown } + | { t: 'tool.progress', sessionId: string, callId: string, chunk: string } + | { t: 'tool.end', sessionId: string, callId: string, ok: boolean, result?: unknown } + | { t: 'subagent.spawn', subagentId: string, backend: SubagentBackend, parentSessionId: string } + | { t: 'subagent.event', subagentId: string, kind: 'stdout'|'stderr'|'status', chunk: string } + | { t: 'subagent.end', subagentId: string, ok: boolean, exit?: number } + | { t: 'skill.proposal', proposalId: string, kind: 'draft'|'retire', payload: SkillProposal } + | { t: 'skill.decision', proposalId: string, decision: 'accepted'|'rejected', by: 'user'|'auto' } + | { t: 'provider.switch', from: ProviderId, to: ProviderId } + | { t: 'policy.gate', candidateId: string, result: 'approved'|'rejected', reason?: string } + | { t: 'budget.warn', sessionId: string, dimension: 'tokens'|'usd'|'wallclock'|'toolCalls', used: number, cap: number } + | { t: 'error', sessionId?: string, code: string, message: string }; +``` + +- **Ink** subscribes to the in-process `EventBus` directly. +- **Web** connects via SSE at `GET /events` (same schema, serialized). +- Both render **from the event stream**, never query mutable state. +- **Version header:** `X-Cockpit-Protocol: 1` on the SSE stream. Bumping it is a major change; renderers warn on mismatch. + +### Transport details + +- Web renderer served by `strand dev` (Vite + Hono); production build via `strand web-build` → `dist/web/`. +- SSE endpoints: `GET /events` (event stream), `POST /input` (user input), `POST /commands/:slash` (slash commands). +- Auth to the local web server: loopback-only, random token written to `~/.strand/cockpit.token`, passed via header. Prevents other local processes from snooping. + +--- + +## §5 — Subagent spawn model + +### Unified interface + +```ts +interface Subagent { + id: string; + backend: 'internal' | 'cli-process' | 'ssh'; + spawn(spec: SpawnSpec): Promise; +} + +interface SubagentHandle { + send(input: string): Promise; // for interactive (tmux / stdin) + events: AsyncIterable; // normalized into core's event schema + status(): Promise; + cancel(): Promise; + budget: BudgetTracker; // inherited cap, child ≤ parent +} +``` + +### Backends + +| Backend | Implementation | Use case | +|---|---|---| +| `internal` | Wrap existing `src/agent/spawn.ts`. Shares memory (brainctl), policy gate, provider router. | Cheap in-process delegation; capability-limited sub-agents | +| `cli-process` | Generic. Takes `{ cmd, args, mode: 'oneshot'\|'interactive', parser: StreamParser }`. Oneshot pipes stdin/stdout; interactive wraps in `tmux` (hidden from user). Ships with parsers for `claude -p --output-format stream-json`, `codex exec --json`, and raw-text passthrough. | `claude`, `codex`, any future CLI agent | +| `ssh` | Wrap existing `src/agent/executor-ssh.ts`. | Remote shell, future remote worker fleet | + +### Budget inheritance + +Every subagent inherits ≤ parent budget on all four dimensions: `tokens`, `usdTicks`, `wallClockMs`, `toolCalls`. Child can't exceed parent's remaining. Enforced at spawn, not trust-the-child. + +### Concurrency + depth caps (from hermes `tools/delegate_tool.py`) + +- `maxDepth: 3` — cockpit (0) → agent (1) → subagent (2) → grand-subagent (3), beyond rejected. +- `maxConcurrentChildren: 3` per parent (configurable via `strand.config.yaml`). +- Heartbeat every 30s during long delegations. +- Stale subagent auto-cancelled at 10 minutes of no progress (override-able per-spawn). + +### Chat-driven spawning + +Slash commands in cockpit chat: +- `/spawn claude ` — delegates to Claude Code skill +- `/spawn codex ` — delegates to Codex skill +- `/spawn internal ` — internal Strand subagent +- `/spawn ssh ` — remote worker + +Each spawned worker gets its own tab (web) / pane (Ink). Worker events stream into the parent transcript AND the worker's own sub-transcript. + +### Claude Code parser (implementation note for S4) + +Ship oneshot-mode default. Example invocation: + +```bash +claude -p "" \ + --output-format stream-json \ + --verbose \ + --include-partial-messages \ + --max-turns 10 \ + --allowedTools "Read,Edit,Bash" \ + --max-budget-usd 2.00 +``` + +Parse newline-delimited JSON. Map `stream_event` → `subagent.event` kind `stdout`. Map `system/api_retry` → `subagent.event` kind `status`. Terminal `result` event carries `session_id`, `num_turns`, `total_cost_usd` — emit as `subagent.end` payload. For interactive mode, wrap in tmux per hermes Claude Code skill (handle trust dialog + bypass-permissions dialog as specified in that skill). + +### Policy gate preservation + +Subagents proposing X/Twitter actions emit `Candidate` — the gate in `src/policy/index.ts` is the only code path that can mint `Candidate`. Subagents cannot import the gate; TS refuses the bypass at compile time. Hard constraint #1. + +--- + +## §6 — Skill lifecycle + +### Storage (option "c": markdown + SQLite) + +``` +src/agent/skills/*.md ← human-readable, git-tracked, frontmatter spec +data/skills.sqlite ← executable record: usage_count, success_count, + token_cost_p50/p95, last_used_at, trust_score, + triggers[], supersedes[], status + (active | retired | draft | queued_draft | queued_retire) +``` + +### Frontmatter shape + +```yaml +--- +name: claude-code +description: Delegate coding tasks to Claude Code CLI +version: 1.0.0 +triggers: ["coding", "refactor", "review", "PR"] +backend: cli-process +spawn_spec: + cmd: claude + args: ["-p", "--output-format", "stream-json", "--verbose"] + parser: claude-code-stream +tools_allowed: [Read, Edit, Bash, Write] +budget: { tokens: 50000, usdTicks: 2000000, wallClockMs: 300000 } +--- +``` + +### Evolution loop (option "iii": reflexion + usage) + +1. **Post-task reflexion** — after every completed task, a lightweight judge model reads the transcript and emits 0-N proposals: `{ kind: 'draft'|'retire', rationale, proposed_frontmatter? }`. +2. **Usage metrics** tick on every skill invocation (success, latency, token cost, user-aborted). +3. **Nightly scorer** (on the consolidator schedule): + - `retire` candidate = hit-rate < 0.15 OR (success-rate < 0.5 AND n ≥ 10) OR (superseded by a higher-scoring skill on same triggers). + - `draft` candidate = reflexion flagged AND ≥3 sessions exhibited the same pattern AND no active skill matches triggers. +4. **All proposals queue.** Cockpit surfaces a "Skill Review" feed. One-click accept/reject. Rejection remembered — same proposal won't re-queue for 30 days. +5. **Audit trail.** Every accept/reject logged to brainctl as a `decision` event with rationale. + +### brainctl integration + +Skills are a memory category. `skill` joins the existing categories (`convention | decision | environment | identity | integration | lesson | preference | project | user`). This reuses: +- W(m) trust gate +- Retirement analysis (the nightly scorer IS `retirement_analysis` filtered to `category=skill`) +- Labile-window rescue +- Trust decay + +### Token-bloat reduction (the actual ask) + +- Skills are NOT dumped into the system prompt. +- Skills retrieved JIT via trigger-match against user's current turn. Top-K (default 3) included. +- A skill's markdown body is the full instruction; never pasted inline unless match score clears a threshold. +- Retired skills removed from retrieval index same minute they're approved. +- `/skills` slash command lists active + queued + retired-with-un-retire. + +--- + +## §7 — Gamified panel disposition + +- **Default `strand` entry point** → drops user into the chat cockpit (web or Ink, user's pick on first run, persists). +- **Legacy panels** accessible via `strand tui --classic` or `/classic`. Zero loss, just not the default. +- **Twitter engine** keeps running when credentials + policy are configured. In the cockpit, it's a registered background subagent — its own tab emitting status events. Chat with the operator without reading per-tweet telemetry. +- **Systems telemetry** that used to live in gamified panels now flows into a collapsible right-rail "Systems" drawer — off by default. Keeps chat context clean. + +--- + +## §8 — Workstream decomposition + +**Ownership principle:** Claude Code takes the TS-hardest seats (typestate, policy gate, core event schema). Devins take web / adapters / storage / parallelizable surface work. + +| Stream | Owner | Scope | Depends on | +|---|---|---|---| +| **S0 — Spec + scaffolding** | Codex (team lead) | Read this spec. Scaffold `src/cockpit/core/` with the §4 event schema. Land empty package skeletons. Stub `SubagentHandle` + `Subagent` interfaces. Set up CI matrix. | — | +| **S1 — Cockpit core + policy-gate preservation** | **Claude Code** | Implement `Transcript`, `EventBus`, `ChatController`, `ProviderRouter`. Prove any chat-driven X-engine action still compiles through `Candidate`. Property tests enforcing hard constraint #1. | S0 | +| **S2 — Auth adapters + provider registry** | Devin-1 | BYOK for anthropic/openai/xai/gemini + `openai-compat`. PKCE device-code for OpenAI. `oauth_external` reader for `~/.claude/.credentials.json` + gemini-cli creds. `~/.strand/auth.json` store with single-writer lock. Picker UI wiring. | S0 | +| **S3 — Web cockpit renderer** | Devin-2 | Vite + Hono app served by `strand dev`. SSE consumer rendering §4 schema. Chat UI + subagent tabs + slash commands + `/skills` review feed. Tailwind + shadcn/ui. | S1 partial (schema + stub events) | +| **S4 — Subagent backends + seed skills** | Devin-3 | `cli-process` backend with `claude -p` + `codex exec` parsers. tmux wrapping for interactive. Seed skills: `claude-code`, `codex`, `pr-review` (port from hermes). Budget inheritance + caps. | S1 | +| **S5 — Skill lifecycle + brainctl integration + Ink renderer** | Devin-4 | `data/skills.sqlite` schema. Usage metric hooks. Reflexion judge. Nightly scorer. Queue + review UI wiring. brainctl `skill` category registration. Ink renderer for the "classic"-preserving path. | S1, S4 seed skills | + +### Integration checkpoints (Codex enforces, not time-boxed — flow-boxed) + +Agents work fast. These are gate conditions, not days. Codex holds the green flag between each one. + +1. **Spec-read gate.** S0 landed, event schema frozen in code. All agents initial the sign-off at the bottom of this spec. Nobody writes feature code until this gate passes. +2. **Alive gate.** S1 + S2 land a streaming BYOK chat in Ink. If any provider's streaming response doesn't render into the Ink transcript, the event schema has a bug — fix before anything else ships. This is the single highest-value checkpoint. +3. **Parity gate.** S3 web cockpit renders the same schema. Parity test: identical event stream → identical transcript in Ink and Web. Divergence = P0. +4. **Spawn gate.** S4 `/spawn claude` and `/spawn codex` both complete a oneshot task end-to-end with streamed output into the cockpit transcript. `--bare` default path verified. Budget inheritance verified (child can't exceed parent remaining). +5. **Skill gate.** S5 scores a low-hit skill below threshold, emits a `queued_retire` proposal, operator approves in the review UI, skill drops out of the retrieval index. Same path for `queued_draft`. +6. **Cutover gate.** Old TUI moves to `strand tui --classic`. Default `strand` enters chat cockpit. `strand.config.yaml` example updated (no default provider). Release branch opened. + +### Kill switches + +- **S2 Anthropic-OAuth-external** → `extra_usage` bug: ship with warning banner, fall back to BYOK. Don't block sprint on Anthropic's billing behavior. +- **S4 `claude -p` parser** → version-mismatch-unreliable: fall back to raw-text parser, log, proceed. +- **S5 reflexion judge** → costs > $1/session: disable by default, keep queue + manual `/skill propose` only. + +### Test matrix (minimum) + +- **Core** — property test: no X-engine action reaches actor without `Candidate`. +- **Auth** — device-code flow against openai (mocked token endpoint in CI). +- **Renderer parity** — record event stream from a scripted chat, replay through Ink and Web, assert identical transcript state. +- **Subagent budget** — spawn child with 50% of parent budget, burn 60% of child quota, assert child aborted, parent proceeds. +- **Skill lifecycle** — seed low-hit skill, tick usage below threshold, run scorer, assert `queued_retire` proposal emitted. + +--- + +## References + +- [NousResearch/hermes-agent](https://github.com/NousResearch/hermes-agent) — primary reference implementation. + - `hermes_cli/auth.py` — provider registry, device-code flow, auth store. + - `tools/delegate_tool.py` — subagent spawn conventions. + - `skills/autonomous-ai-agents/claude-code/SKILL.md` — Claude Code wrapping pattern. + - `skills/autonomous-ai-agents/codex/SKILL.md` — Codex wrapping pattern. + - Issue [#12905](https://github.com/NousResearch/hermes-agent/issues/12905) — Anthropic OAuth `extra_usage` routing. +- [Claude Code CLI reference](https://code.claude.com/docs/en/cli-reference) — flags, output formats, session management. +- [OpenAI device authorization](https://auth.openai.com/codex/device) — the endpoint users enter their device code at. +- Strand `CLAUDE.md` — existing project non-negotiables (policy gate, X API tier reality, Twitter engine architecture). +- Strand `docs/ARCHITECTURE.md` — existing architecture to preserve. + +--- + +## Sign-off + +Every stream owner acknowledges they've read this spec by appending their name + date below before writing code. + +- [x] Codex (team lead) — 2026-04-24 +- [ ] Claude Code +- [ ] Devin-1 +- [ ] Devin-2 +- [ ] Devin-3 +- [ ] Devin-4 diff --git a/src/agent/skills/claude-code.md b/src/agent/skills/claude-code.md new file mode 100644 index 0000000..67f807a --- /dev/null +++ b/src/agent/skills/claude-code.md @@ -0,0 +1,54 @@ +--- +name: claude-code +description: Delegate coding tasks to Claude Code CLI +version: 1.0.0 +triggers: ["coding", "refactor", "review", "PR", "debug", "implement"] +backend: cli-process +spawn_spec: + cmd: claude + args: ["-p", "--output-format", "stream-json", "--verbose"] + parser: claude-code-stream +tools_allowed: [Read, Edit, Bash, Write] +budget: { tokens: 50000, usdTicks: 2000000, wallClockMs: 300000 } +--- + +# Claude Code + +Delegate a coding task to Claude Code running as a CLI subprocess. + +## When to use + +- The task requires reading, editing, or creating files in a repository. +- The task involves debugging, refactoring, or implementing features. +- You need a capable coding agent with file system and shell access. + +## Invocation + +The cockpit spawns `claude -p ""` with `--output-format stream-json` +for structured streaming output. In BYOK (api_key) mode, `--bare` is +added automatically for fastest startup and lowest overhead. In +`oauth_external` mode, `--bare` is never passed (hard constraint #7). + +## Allowed tools + +The subagent is restricted to: `Read`, `Edit`, `Bash`, `Write`. +Additional tools can be granted per-spawn via `--allowedTools`. + +## Budget + +Default: 50k tokens, $0.002 USD, 5 min wall clock. Child budget is +capped at half the parent's remaining budget on all dimensions. + +## Output + +Stdout is parsed as newline-delimited JSON (`stream-json` format). +Events are normalized into CockpitEvent schema: +- Content deltas -> `subagent.event` kind `stdout` +- System/retry notices -> `subagent.event` kind `status` +- Terminal result -> `subagent.end` with cost metadata + +## Notes + +- `--max-turns 10` is a sensible default for bounded tasks. +- `--max-budget-usd 2.00` prevents runaway spend on a single delegation. +- Interactive mode (tmux wrapping) is planned but not yet implemented. diff --git a/src/agent/skills/codex.md b/src/agent/skills/codex.md new file mode 100644 index 0000000..f3cb2d6 --- /dev/null +++ b/src/agent/skills/codex.md @@ -0,0 +1,50 @@ +--- +name: codex +description: Delegate coding tasks to OpenAI Codex CLI +version: 1.0.0 +triggers: ["coding", "implement", "fix", "generate", "scaffold"] +backend: cli-process +spawn_spec: + cmd: codex + args: ["exec", "--json"] + parser: codex-exec +tools_allowed: [Read, Edit, Bash] +budget: { tokens: 50000, usdTicks: 2000000, wallClockMs: 300000 } +--- + +# Codex CLI + +Delegate a coding task to OpenAI's Codex CLI agent. + +## When to use + +- The task requires generating, editing, or scaffolding code. +- You want to use OpenAI models for the subtask. +- The task is well-scoped and can run as a oneshot execution. + +## Invocation + +The cockpit spawns `codex exec --json ""` for structured output. +The task description is passed as stdin in oneshot mode. + +## Allowed tools + +The subagent is restricted to: `Read`, `Edit`, `Bash`. + +## Budget + +Default: 50k tokens, $0.002 USD, 5 min wall clock. Child budget is +capped at half the parent's remaining budget on all dimensions. + +## Output + +Stdout is parsed as newline-delimited JSON when available. The parser +falls back to raw-text passthrough if the JSON format is unstable. +Events are normalized into CockpitEvent schema. + +## Known risks + +- `codex exec --json` output format is not fully stable. The parser + includes a raw-text fallback for resilience. +- Version mismatches may change the JSON schema without notice. Monitor + parser errors and fall back to raw-text if needed (kill switch S4). diff --git a/src/agent/skills/index.ts b/src/agent/skills/index.ts index 2d4ccd3..b7a8f2f 100644 --- a/src/agent/skills/index.ts +++ b/src/agent/skills/index.ts @@ -28,3 +28,24 @@ export type { SkillProposalStore, } from "./auto-create"; export { SqliteSkillProposalStore, makeSqliteSkillProposalStore } from "./proposal-store"; + +// Skill lifecycle (§6): +export { + SkillRecordStore, + SkillDecisionStore, + acceptProposal, + rejectProposal, + runNightlyScorer, + toBrainctlDecisionEvent, + tokenCostP50, + tokenCostP95, +} from "./lifecycle"; +export type { + BrainctlDecisionEvent, + ScorerOpts, + ScorerResult, + SkillDecision, + SkillRecord, + SkillStatus, + UsageEvent, +} from "./lifecycle"; diff --git a/src/agent/skills/lifecycle.ts b/src/agent/skills/lifecycle.ts new file mode 100644 index 0000000..1818954 --- /dev/null +++ b/src/agent/skills/lifecycle.ts @@ -0,0 +1,450 @@ +/** + * Skill lifecycle — executable skill records, usage metrics, nightly scorer, + * and brainctl decision integration. + * + * Markdown skill files remain the human-readable source of truth; + * this module tracks runtime metrics in SQLite and manages state transitions. + * + * Statuses: active | retired | draft | queued_draft | queued_retire + * No silent retirement — everything queues for operator approval. + */ + +import { db as defaultDb } from "@/db"; +import type { Database as BetterSqliteDatabase } from "better-sqlite3"; + +// ─── Types ───────────────────────────────────────────────────────────────── + +export type SkillStatus = "active" | "retired" | "draft" | "queued_draft" | "queued_retire"; + +export interface SkillRecord { + name: string; + status: SkillStatus; + usageCount: number; + successCount: number; + tokenCostSamples: number[]; + lastUsedAt: string | null; + trustScore: number; + triggers: string[]; + supersedes: string[]; + createdAt: string; + updatedAt: string; +} + +export interface SkillDecision { + id: string; + skillName: string; + proposalKind: "draft" | "retire"; + decision: "accepted" | "rejected"; + decidedBy: "user" | "auto"; + rationale: string | null; + suppressedUntil: string | null; + createdAt: string; +} + +export interface UsageEvent { + skillName: string; + success: boolean; + tokenCost: number; +} + +export interface ScorerResult { + queuedRetire: string[]; + queuedDraft: string[]; + skipped: string[]; +} + +// ─── Percentile helpers ──────────────────────────────────────────────────── + +const MAX_COST_SAMPLES = 100; + +function percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const idx = Math.ceil((p / 100) * sorted.length) - 1; + return sorted[Math.max(0, idx)] ?? 0; +} + +export function tokenCostP50(samples: number[]): number { + const s = [...samples].sort((a, b) => a - b); + return percentile(s, 50); +} + +export function tokenCostP95(samples: number[]): number { + const s = [...samples].sort((a, b) => a - b); + return percentile(s, 95); +} + +// ─── SQLite row mapping ──────────────────────────────────────────────────── + +interface SkillRecordRow { + name: string; + status: string; + usage_count: number; + success_count: number; + token_cost_samples_json: string | null; + last_used_at: string | null; + trust_score: number; + triggers_json: string | null; + supersedes_json: string | null; + created_at: string; + updated_at: string; +} + +interface SkillDecisionRow { + id: string; + skill_name: string; + proposal_kind: string; + decision: string; + decided_by: string; + rationale: string | null; + suppressed_until: string | null; + created_at: string; +} + +function parseJsonArray(raw: string | null): string[] { + if (!raw) return []; + try { + const arr: unknown = JSON.parse(raw); + return Array.isArray(arr) ? arr.filter((v): v is string => typeof v === "string") : []; + } catch { + return []; + } +} + +function parseNumberArray(raw: string | null): number[] { + if (!raw) return []; + try { + const arr: unknown = JSON.parse(raw); + return Array.isArray(arr) ? arr.filter((v): v is number => typeof v === "number") : []; + } catch { + return []; + } +} + +function rowToRecord(r: SkillRecordRow): SkillRecord { + return { + name: r.name, + status: r.status as SkillStatus, + usageCount: r.usage_count, + successCount: r.success_count, + tokenCostSamples: parseNumberArray(r.token_cost_samples_json), + lastUsedAt: r.last_used_at, + trustScore: r.trust_score, + triggers: parseJsonArray(r.triggers_json), + supersedes: parseJsonArray(r.supersedes_json), + createdAt: r.created_at, + updatedAt: r.updated_at, + }; +} + +function rowToDecision(r: SkillDecisionRow): SkillDecision { + return { + id: r.id, + skillName: r.skill_name, + proposalKind: r.proposal_kind as "draft" | "retire", + decision: r.decision as "accepted" | "rejected", + decidedBy: r.decided_by as "user" | "auto", + rationale: r.rationale, + suppressedUntil: r.suppressed_until, + createdAt: r.created_at, + }; +} + +// ─── SkillRecordStore ────────────────────────────────────────────────────── + +export class SkillRecordStore { + private readonly db: BetterSqliteDatabase; + + constructor(database?: BetterSqliteDatabase) { + this.db = database ?? defaultDb(); + } + + upsert( + name: string, + fields: Partial>, + ): void { + const now = new Date().toISOString(); + this.db + .prepare( + `INSERT INTO skill_records (name, status, triggers_json, supersedes_json, trust_score, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + status = COALESCE(?, status), + triggers_json = COALESCE(?, triggers_json), + supersedes_json = COALESCE(?, supersedes_json), + trust_score = COALESCE(?, trust_score), + updated_at = ?`, + ) + .run( + name, + fields.status ?? "active", + fields.triggers ? JSON.stringify(fields.triggers) : null, + fields.supersedes ? JSON.stringify(fields.supersedes) : null, + fields.trustScore ?? 1.0, + now, + now, + fields.status ?? null, + fields.triggers ? JSON.stringify(fields.triggers) : null, + fields.supersedes ? JSON.stringify(fields.supersedes) : null, + fields.trustScore ?? null, + now, + ); + } + + get(name: string): SkillRecord | null { + const row = this.db.prepare("SELECT * FROM skill_records WHERE name = ?").get(name) as + | SkillRecordRow + | undefined; + return row ? rowToRecord(row) : null; + } + + listByStatus(status: SkillStatus, limit = 100): SkillRecord[] { + const rows = this.db + .prepare("SELECT * FROM skill_records WHERE status = ? ORDER BY updated_at DESC LIMIT ?") + .all(status, limit) as SkillRecordRow[]; + return rows.map(rowToRecord); + } + + listActive(): SkillRecord[] { + return this.listByStatus("active"); + } + + listAll(limit = 500): SkillRecord[] { + const rows = this.db + .prepare("SELECT * FROM skill_records ORDER BY name LIMIT ?") + .all(limit) as SkillRecordRow[]; + return rows.map(rowToRecord); + } + + recordUsage(event: UsageEvent): void { + const now = new Date().toISOString(); + const existing = this.get(event.skillName); + if (!existing) { + this.upsert(event.skillName, {}); + } + const samples = existing?.tokenCostSamples ?? []; + samples.push(event.tokenCost); + if (samples.length > MAX_COST_SAMPLES) { + samples.splice(0, samples.length - MAX_COST_SAMPLES); + } + + this.db + .prepare( + `UPDATE skill_records SET + usage_count = usage_count + 1, + success_count = success_count + CASE WHEN ? THEN 1 ELSE 0 END, + token_cost_samples_json = ?, + last_used_at = ?, + updated_at = ? + WHERE name = ?`, + ) + .run(event.success ? 1 : 0, JSON.stringify(samples), now, now, event.skillName); + } + + updateStatus(name: string, status: SkillStatus): void { + const now = new Date().toISOString(); + this.db + .prepare("UPDATE skill_records SET status = ?, updated_at = ? WHERE name = ?") + .run(status, now, name); + } + + removeFromIndex(name: string): void { + this.updateStatus(name, "retired"); + } +} + +// ─── Nightly scorer ──────────────────────────────────────────────────────── + +const HIT_RATE_THRESHOLD = 0.15; +const SUCCESS_RATE_THRESHOLD = 0.5; +const MIN_USAGE_FOR_SUCCESS_RATE = 10; +const SUPPRESSION_DAYS = 30; + +export interface ScorerOpts { + store: SkillRecordStore; + decisionStore: SkillDecisionStore; + totalInvocations: number; +} + +export function runNightlyScorer(opts: ScorerOpts): ScorerResult { + const { store, decisionStore, totalInvocations } = opts; + const active = store.listActive(); + const result: ScorerResult = { queuedRetire: [], queuedDraft: [], skipped: [] }; + + for (const skill of active) { + if (decisionStore.isSuppressed(skill.name, "retire")) { + result.skipped.push(skill.name); + continue; + } + + const hitRate = totalInvocations > 0 ? skill.usageCount / totalInvocations : 0; + const successRate = + skill.usageCount >= MIN_USAGE_FOR_SUCCESS_RATE ? skill.successCount / skill.usageCount : 1.0; + + const isSuperseded = skill.supersedes.length === 0 && isSupersededByOther(skill.name, store); + + if ( + hitRate < HIT_RATE_THRESHOLD || + (successRate < SUCCESS_RATE_THRESHOLD && skill.usageCount >= MIN_USAGE_FOR_SUCCESS_RATE) || + isSuperseded + ) { + store.updateStatus(skill.name, "queued_retire"); + result.queuedRetire.push(skill.name); + } + } + + return result; +} + +function isSupersededByOther(name: string, store: SkillRecordStore): boolean { + const all = store.listActive(); + return all.some((s) => s.name !== name && s.supersedes.includes(name)); +} + +// ─── SkillDecisionStore ──────────────────────────────────────────────────── + +export class SkillDecisionStore { + private readonly db: BetterSqliteDatabase; + + constructor(database?: BetterSqliteDatabase) { + this.db = database ?? defaultDb(); + } + + record(decision: Omit): void { + const suppressedUntil = + decision.decision === "rejected" + ? new Date(Date.now() + SUPPRESSION_DAYS * 24 * 60 * 60 * 1000).toISOString() + : null; + + this.db + .prepare( + `INSERT INTO skill_decisions (id, skill_name, proposal_kind, decision, decided_by, rationale, suppressed_until) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + decision.id, + decision.skillName, + decision.proposalKind, + decision.decision, + decision.decidedBy, + decision.rationale ?? null, + suppressedUntil, + ); + } + + isSuppressed(skillName: string, proposalKind: string): boolean { + const now = new Date().toISOString(); + const row = this.db + .prepare( + `SELECT 1 FROM skill_decisions + WHERE skill_name = ? AND proposal_kind = ? AND decision = 'rejected' + AND suppressed_until > ? + LIMIT 1`, + ) + .get(skillName, proposalKind, now); + return row !== undefined; + } + + listForSkill(skillName: string, limit = 50): SkillDecision[] { + const rows = this.db + .prepare( + "SELECT * FROM skill_decisions WHERE skill_name = ? ORDER BY created_at DESC LIMIT ?", + ) + .all(skillName, limit) as SkillDecisionRow[]; + return rows.map(rowToDecision); + } +} + +// ─── Brainctl adapter ────────────────────────────────────────────────────── + +export interface BrainctlDecisionEvent { + type: "skill_decision"; + skillName: string; + proposalKind: "draft" | "retire"; + decision: "accepted" | "rejected"; + decidedBy: "user" | "auto"; + rationale: string | null; + timestamp: string; +} + +export function toBrainctlDecisionEvent(d: SkillDecision): BrainctlDecisionEvent { + return { + type: "skill_decision", + skillName: d.skillName, + proposalKind: d.proposalKind, + decision: d.decision, + decidedBy: d.decidedBy, + rationale: d.rationale, + timestamp: d.createdAt, + }; +} + +/** + * Accept a skill lifecycle proposal. + * - retire proposals: mark skill as retired, remove from retrieval index + * - draft proposals: mark skill as active (promote from draft) + */ +export function acceptProposal(opts: { + store: SkillRecordStore; + decisionStore: SkillDecisionStore; + skillName: string; + proposalKind: "draft" | "retire"; + decidedBy: "user" | "auto"; + rationale?: string; +}): SkillDecision { + const { store, decisionStore, skillName, proposalKind, decidedBy, rationale } = opts; + const id = `sd_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + + if (proposalKind === "retire") { + store.removeFromIndex(skillName); + } else { + store.updateStatus(skillName, "active"); + } + + const decision: Omit = { + id, + skillName, + proposalKind, + decision: "accepted", + decidedBy, + rationale: rationale ?? null, + suppressedUntil: null, + }; + decisionStore.record(decision); + + return { ...decision, createdAt: new Date().toISOString() }; +} + +/** + * Reject a skill lifecycle proposal. + * Suppresses the same proposal for 30 days. + */ +export function rejectProposal(opts: { + store: SkillRecordStore; + decisionStore: SkillDecisionStore; + skillName: string; + proposalKind: "draft" | "retire"; + decidedBy: "user" | "auto"; + rationale?: string; +}): SkillDecision { + const { store, decisionStore, skillName, proposalKind, decidedBy, rationale } = opts; + const id = `sd_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + + // Revert to active if it was queued + const record = store.get(skillName); + if (record && (record.status === "queued_retire" || record.status === "queued_draft")) { + store.updateStatus(skillName, record.status === "queued_retire" ? "active" : "draft"); + } + + const decision: Omit = { + id, + skillName, + proposalKind, + decision: "rejected", + decidedBy, + rationale: rationale ?? null, + suppressedUntil: null, + }; + decisionStore.record(decision); + + return { ...decision, createdAt: new Date().toISOString() }; +} diff --git a/src/agent/skills/pr-review.md b/src/agent/skills/pr-review.md new file mode 100644 index 0000000..80f2f84 --- /dev/null +++ b/src/agent/skills/pr-review.md @@ -0,0 +1,52 @@ +--- +name: pr-review +description: Review a pull request for correctness, security, and style +version: 1.0.0 +triggers: ["review", "PR", "pull request", "code review"] +backend: cli-process +spawn_spec: + cmd: claude + args: ["-p", "--output-format", "stream-json", "--verbose", "--max-turns", "5"] + parser: claude-code-stream +tools_allowed: [Read, Bash] +budget: { tokens: 30000, usdTicks: 1500000, wallClockMs: 180000 } +--- + +# PR Review + +Review a pull request for correctness, security, and performance issues. + +## When to use + +- A pull request needs review before merge. +- You want an automated first pass on code quality. +- The operator asks you to review changes in a branch or PR. + +## Invocation + +The cockpit spawns Claude Code with a review-focused prompt. The task +should include the PR URL, branch name, or diff context. The subagent +reads the relevant files and provides structured feedback. + +## Allowed tools + +Read-only: `Read` for file inspection, `Bash` for git commands +(`git diff`, `git log`, `git show`). No write tools. + +## Budget + +Tighter than general coding: 30k tokens, $0.0015 USD, 3 min wall clock. +Reviews should be fast and focused. + +## Review checklist + +The subagent should evaluate: +1. **Correctness** — does the code do what it claims? +2. **Security** — no exposed secrets, injection vectors, or auth bypasses. +3. **Performance** — no N+1 queries, unbounded loops, or memory leaks. +4. **Style** — follows existing conventions (skip nitpicks). + +## Output + +Structured review comments normalized through the Claude Code stream +parser into CockpitEvent schema. diff --git a/src/auth/auth-store.ts b/src/auth/auth-store.ts new file mode 100644 index 0000000..563cd62 --- /dev/null +++ b/src/auth/auth-store.ts @@ -0,0 +1,183 @@ +/** + * Cockpit auth store — persists the user's active provider choice and + * per-provider auth state to `~/.strand/auth.json`. + * + * Shape mirrors the spec (S3 Auth store shape): + * { active_provider, providers, suppressed_sources } + * + * Rules (verbatim from hermes): + * 1. No implicit use of external credentials (hard constraint #5). + * 2. `suppressed_sources` blacklists a discovery path per provider. + * 3. Single-writer file lock during refresh. + */ + +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { z } from "zod"; +import type { AuthType, ProviderId } from "./provider-registry"; +import { ProviderIdSchema } from "./provider-registry"; + +export const ApiKeyEntrySchema = z.object({ + auth_type: z.literal("api_key"), + source: z.string().min(1), +}); + +export const OAuthDeviceCodeEntrySchema = z.object({ + auth_type: z.literal("oauth_device_code"), + tokens: z.record(z.string()), + expires_at: z.string().datetime(), +}); + +export const OAuthExternalEntrySchema = z.object({ + auth_type: z.literal("oauth_external"), + credential_path: z.string().min(1), +}); + +export const AuthEntrySchema = z.discriminatedUnion("auth_type", [ + ApiKeyEntrySchema, + OAuthDeviceCodeEntrySchema, + OAuthExternalEntrySchema, +]); +export type AuthEntry = z.infer; + +export const AuthStoreDataSchema = z.object({ + active_provider: ProviderIdSchema.nullable(), + providers: z.record(ProviderIdSchema, AuthEntrySchema), + suppressed_sources: z.record(ProviderIdSchema, z.array(z.string())), +}); +export type AuthStoreData = z.infer; + +function emptyStore(): AuthStoreData { + return { + active_provider: null, + providers: {}, + suppressed_sources: {}, + }; +} + +export interface CockpitAuthStoreOpts { + /** Override the default path for testing. */ + path?: string; +} + +/** + * Manages `~/.strand/auth.json` with single-writer locking. + * + * All mutations go through `update()` which holds a lock file for the + * duration of the write. Reads are lock-free (stale reads are acceptable + * since the only writer is the cockpit process on this machine). + */ +export class CockpitAuthStore { + readonly path: string; + private lockHeld = false; + + constructor(opts?: CockpitAuthStoreOpts) { + this.path = opts?.path ?? join(homedir(), ".strand", "auth.json"); + } + + read(): AuthStoreData { + if (!existsSync(this.path)) return emptyStore(); + const raw = readFileSync(this.path, "utf-8"); + const parsed: unknown = JSON.parse(raw); + return AuthStoreDataSchema.parse(parsed); + } + + /** + * Apply `fn` to the current store state and write the result atomically. + * Acquires a local lock to prevent concurrent writes during refresh flows. + */ + update(fn: (current: AuthStoreData) => AuthStoreData): AuthStoreData { + this.acquireLock(); + try { + const current = this.read(); + const next = AuthStoreDataSchema.parse(fn(current)); + const dir = dirname(this.path); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + writeFileSync(this.path, `${JSON.stringify(next, null, 2)}\n`, { + mode: 0o600, + }); + return next; + } finally { + this.releaseLock(); + } + } + + activeProvider(): string | null { + return this.read().active_provider; + } + + setActiveProvider(id: ProviderId, entry: AuthEntry): AuthStoreData { + return this.update((s) => ({ + ...s, + active_provider: id, + providers: { ...s.providers, [id]: entry }, + })); + } + + clearProvider(id: ProviderId): AuthStoreData { + return this.update((s) => { + const providers = { ...s.providers }; + delete providers[id]; + return { + ...s, + active_provider: s.active_provider === id ? null : s.active_provider, + providers, + }; + }); + } + + isSuppressed(providerId: ProviderId, source: string): boolean { + const data = this.read(); + const list = data.suppressed_sources[providerId]; + return list?.includes(source) ?? false; + } + + suppressSource(providerId: ProviderId, source: string): AuthStoreData { + return this.update((s) => { + const existing = s.suppressed_sources[providerId] ?? []; + if (existing.includes(source)) return s; + return { + ...s, + suppressed_sources: { + ...s.suppressed_sources, + [providerId]: [...existing, source], + }, + }; + }); + } + + providerAuthType(id: ProviderId): AuthType | null { + const data = this.read(); + const entry = data.providers[id]; + return (entry?.auth_type as AuthType) ?? null; + } + + private acquireLock(): void { + if (this.lockHeld) throw new Error("CockpitAuthStore: lock already held (re-entrant write)"); + const lockPath = `${this.path}.lock`; + const dir = dirname(lockPath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + try { + writeFileSync(lockPath, `${process.pid}\n`, { flag: "wx", mode: 0o600 }); + this.lockHeld = true; + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === "EEXIST") { + throw new Error( + `CockpitAuthStore: lock file exists at ${lockPath}. Another process may be writing. Remove manually if stale.`, + ); + } + throw err; + } + } + + private releaseLock(): void { + const lockPath = `${this.path}.lock`; + try { + unlinkSync(lockPath); + } catch { + // lock file already gone — acceptable + } + this.lockHeld = false; + } +} diff --git a/src/auth/device-code.ts b/src/auth/device-code.ts new file mode 100644 index 0000000..6ecfab3 --- /dev/null +++ b/src/auth/device-code.ts @@ -0,0 +1,186 @@ +/** + * OpenAI OAuth device-code flow scaffolding. + * + * Reference: spec S3 "Device-code flow reference (OpenAI)" and hermes + * `_codex_device_code_login`. + * + * Flow: + * 1. POST /api/accounts/deviceauth/usercode → { user_code, device_auth_id, interval } + * 2. Show user: open https://auth.openai.com/codex/device, enter code + * 3. Poll: POST /api/accounts/deviceauth/token → 200 { authorization_code, code_verifier } | 403/404 + * 4. Exchange: POST /oauth/token → { access_token, refresh_token, id_token, expires_in } + * + * Max wait 15 minutes. Poll interval >= 3s. + * + * The `DeviceCodeHttpClient` interface is fully mockable for CI tests. + */ + +import { z } from "zod"; + +export const OPENAI_AUTH_BASE = "https://auth.openai.com"; +export const OPENAI_DEVICE_URL = "https://auth.openai.com/codex/device"; +export const DEVICE_CODE_MAX_WAIT_MS = 15 * 60 * 1000; +export const DEVICE_CODE_MIN_POLL_INTERVAL_MS = 3000; + +export const UserCodeResponseSchema = z.object({ + user_code: z.string().min(1), + device_auth_id: z.string().min(1), + interval: z.number().int().nonnegative(), +}); +export type UserCodeResponse = z.infer; + +export const TokenPollSuccessSchema = z.object({ + authorization_code: z.string().min(1), + code_verifier: z.string().min(1), +}); +export type TokenPollSuccess = z.infer; + +export const TokenSetSchema = z.object({ + access_token: z.string().min(1), + refresh_token: z.string().min(1), + id_token: z.string().optional(), + expires_in: z.number().int().positive(), +}); +export type TokenSet = z.infer; + +export type TokenPollResult = { status: "pending" } | { status: "success"; data: TokenPollSuccess }; + +/** + * HTTP-level interface for the device-code flow. Fully mockable — + * CI tests inject a stub that returns canned responses. + */ +export interface DeviceCodeHttpClient { + requestUserCode(clientId: string): Promise; + pollToken(deviceAuthId: string, userCode: string): Promise; + exchangeToken(params: { + authorizationCode: string; + codeVerifier: string; + clientId: string; + redirectUri: string; + }): Promise; +} + +/** + * Real HTTP client that talks to auth.openai.com. + * Uses `fetch` (Node 22+ built-in). + */ +export class OpenAIDeviceCodeClient implements DeviceCodeHttpClient { + private readonly baseUrl: string; + + constructor(baseUrl: string = OPENAI_AUTH_BASE) { + this.baseUrl = baseUrl; + } + + async requestUserCode(clientId: string): Promise { + const res = await fetch(`${this.baseUrl}/api/accounts/deviceauth/usercode`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ client_id: clientId }), + }); + if (!res.ok) { + throw new DeviceCodeError(`usercode request failed: ${res.status} ${res.statusText}`); + } + const body: unknown = await res.json(); + return UserCodeResponseSchema.parse(body); + } + + async pollToken(deviceAuthId: string, userCode: string): Promise { + const res = await fetch(`${this.baseUrl}/api/accounts/deviceauth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ device_auth_id: deviceAuthId, user_code: userCode }), + }); + if (res.status === 403 || res.status === 404) { + return { status: "pending" }; + } + if (!res.ok) { + throw new DeviceCodeError(`token poll failed: ${res.status} ${res.statusText}`); + } + const body: unknown = await res.json(); + return { status: "success", data: TokenPollSuccessSchema.parse(body) }; + } + + async exchangeToken(params: { + authorizationCode: string; + codeVerifier: string; + clientId: string; + redirectUri: string; + }): Promise { + const formBody = new URLSearchParams({ + grant_type: "authorization_code", + code: params.authorizationCode, + redirect_uri: params.redirectUri, + client_id: params.clientId, + code_verifier: params.codeVerifier, + }); + const res = await fetch(`${this.baseUrl}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: formBody.toString(), + }); + if (!res.ok) { + throw new DeviceCodeError(`token exchange failed: ${res.status} ${res.statusText}`); + } + const body: unknown = await res.json(); + return TokenSetSchema.parse(body); + } +} + +export class DeviceCodeError extends Error { + constructor(message: string) { + super(message); + this.name = "DeviceCodeError"; + } +} + +/** + * Orchestrates the full device-code flow. Calls the HTTP client, polls with + * backoff, and returns the final token set. + * + * @param client - injectable HTTP client (mock for tests) + * @param clientId - OpenAI OAuth client ID + * @param onUserCode - callback to display the user code + URL to the user + */ +export async function runDeviceCodeFlow(opts: { + client: DeviceCodeHttpClient; + clientId: string; + redirectUri?: string; + onUserCode: (info: { userCode: string; verificationUrl: string }) => void; + /** Override for tests — minimum poll interval in ms. Production default: 3000. */ + _minPollIntervalMs?: number; + /** Override for tests — max wait in ms. Production default: 15 min. */ + _maxWaitMs?: number; +}): Promise { + const { client, clientId, onUserCode } = opts; + const redirectUri = opts.redirectUri ?? "https://auth.openai.com/codex/device/callback"; + const minPoll = opts._minPollIntervalMs ?? DEVICE_CODE_MIN_POLL_INTERVAL_MS; + const maxWait = opts._maxWaitMs ?? DEVICE_CODE_MAX_WAIT_MS; + + const codeResponse = await client.requestUserCode(clientId); + onUserCode({ + userCode: codeResponse.user_code, + verificationUrl: OPENAI_DEVICE_URL, + }); + + const intervalMs = Math.max(codeResponse.interval * 1000, minPoll); + const deadline = Date.now() + maxWait; + + while (Date.now() < deadline) { + await sleep(intervalMs); + const result = await client.pollToken(codeResponse.device_auth_id, codeResponse.user_code); + if (result.status === "success") { + return client.exchangeToken({ + authorizationCode: result.data.authorization_code, + codeVerifier: result.data.code_verifier, + clientId, + redirectUri, + }); + } + } + + throw new DeviceCodeError("device-code flow timed out after 15 minutes"); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/auth/env-store.ts b/src/auth/env-store.ts index 0a6e1e2..828ecd6 100644 --- a/src/auth/env-store.ts +++ b/src/auth/env-store.ts @@ -9,7 +9,6 @@ export class EnvCredentialStore implements CredentialStore { readonly name = "env"; async get(key: string): Promise { - // biome-ignore lint/complexity/useLiteralKeys: process.env has an index signature const v = process.env[key]; return v && v.length > 0 ? v : undefined; } diff --git a/src/auth/external-discovery.ts b/src/auth/external-discovery.ts new file mode 100644 index 0000000..8102842 --- /dev/null +++ b/src/auth/external-discovery.ts @@ -0,0 +1,140 @@ +/** + * External credential discovery — reads credentials written by other CLI + * tools installed on the same machine. + * + * Hard constraints: + * #3 — `oauth_external` is local-only. + * #4 — Anthropic routes third-party OAuth to `extra_usage` billing pool; + * must surface warning before first call. + * #5 — Discovery NEVER auto-activates a provider. Results are offered as + * selectable sources in the picker. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; + +export interface ExternalCredentialResult { + readonly found: boolean; + readonly path: string; + readonly localOnly: true; + readonly billingWarning?: string; + readonly token?: string; +} + +const ANTHROPIC_BILLING_WARNING = + "Anthropic routes third-party OAuth clients to the extra_usage billing pool, " + + "which is empty for most users. You may incur metered API charges on your " + + "Claude Pro/Max subscription. See hermes-agent issue #12905."; + +/** + * Claude Code credentials file schema — only the fields we need. + * Path: `~/.claude/.credentials.json` + */ +const ClaudeCodeCredentialsSchema = z.object({ + accessToken: z.string().min(1).optional(), + oauthAccessToken: z.string().min(1).optional(), +}); + +/** + * Discover Claude Code OAuth credentials on this machine. + * + * Returns found=true if `~/.claude/.credentials.json` exists and contains a + * usable token. The billing warning is ALWAYS attached when found (hard + * constraint #4). The caller MUST surface this warning before the first call. + */ +export function discoverClaudeCodeCredentials(homeOverride?: string): ExternalCredentialResult { + const home = homeOverride ?? homedir(); + const credPath = join(home, ".claude", ".credentials.json"); + + if (!existsSync(credPath)) { + return { found: false, path: credPath, localOnly: true }; + } + + try { + const raw = readFileSync(credPath, "utf-8"); + const parsed: unknown = JSON.parse(raw); + const creds = ClaudeCodeCredentialsSchema.parse(parsed); + const token = creds.oauthAccessToken ?? creds.accessToken; + if (!token) { + return { found: false, path: credPath, localOnly: true }; + } + return { + found: true, + path: credPath, + localOnly: true, + billingWarning: ANTHROPIC_BILLING_WARNING, + token, + }; + } catch { + return { found: false, path: credPath, localOnly: true }; + } +} + +/** + * Gemini CLI credentials file schema — only the fields we need. + * Path: varies by platform; typically `~/.config/gemini-cli/oauth_creds.json` + * or `~/.qwen/oauth_creds.json`. + */ +const GeminiCliCredentialsSchema = z.object({ + access_token: z.string().min(1).optional(), + client_id: z.string().optional(), + client_secret: z.string().optional(), + refresh_token: z.string().optional(), +}); + +const GEMINI_CLI_PATHS = [ + join(".config", "gemini-cli", "oauth_creds.json"), + join(".qwen", "oauth_creds.json"), +] as const; + +/** + * Discover gemini-cli OAuth credentials on this machine. + * + * Checks known credential paths. Returns found=true if any contains a + * usable access token. No billing warning for Gemini (Google handles + * third-party OAuth differently). + */ +export function discoverGeminiCliCredentials(homeOverride?: string): ExternalCredentialResult { + const home = homeOverride ?? homedir(); + + for (const relPath of GEMINI_CLI_PATHS) { + const credPath = join(home, relPath); + if (!existsSync(credPath)) continue; + + try { + const raw = readFileSync(credPath, "utf-8"); + const parsed: unknown = JSON.parse(raw); + const creds = GeminiCliCredentialsSchema.parse(parsed); + if (creds.access_token) { + return { + found: true, + path: credPath, + localOnly: true, + token: creds.access_token, + }; + } + } catch {} + } + + const firstPath = GEMINI_CLI_PATHS[0] ?? ""; + const defaultPath = join(home, firstPath); + return { found: false, path: defaultPath, localOnly: true }; +} + +/** + * Run all external credential discovery probes. Returns a map of + * provider ID to discovery result. Caller decides what to show in the picker. + * + * IMPORTANT: Discovery NEVER auto-activates a provider (hard constraint #5). + */ +export function discoverAllExternalCredentials(homeOverride?: string): { + anthropic: ExternalCredentialResult; + gemini: ExternalCredentialResult; +} { + return { + anthropic: discoverClaudeCodeCredentials(homeOverride), + gemini: discoverGeminiCliCredentials(homeOverride), + }; +} diff --git a/src/auth/index.ts b/src/auth/index.ts index 1cc1ca1..cfc6e2e 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -19,6 +19,43 @@ export { OAuthCredentialStore, type OAuthProviderStrategy } from "./oauth-store" export { makeXOAuthStrategy } from "./oauth-x"; export { TenantScopedCredentialStore } from "./tenant-store"; +// Cockpit auth — provider registry, auth store, external discovery, device-code +export { + type AuthMode, + type AuthSource, + type AuthType, + type HostConstraint, + type ProviderId, + type ProviderDef, + ProviderIdSchema, + AuthTypeSchema, + availableAuthModes, + getProvider, + listProviders, + requiresBaseUrl, +} from "./provider-registry"; +export { + type AuthEntry, + type AuthStoreData, + AuthStoreDataSchema, + CockpitAuthStore, +} from "./auth-store"; +export { + type ExternalCredentialResult, + discoverAllExternalCredentials, + discoverClaudeCodeCredentials, + discoverGeminiCliCredentials, +} from "./external-discovery"; +export { + type DeviceCodeHttpClient, + type TokenPollResult, + type TokenSet, + type UserCodeResponse, + DeviceCodeError, + OpenAIDeviceCodeClient, + runDeviceCodeFlow, +} from "./device-code"; + let _default: CredentialStore | null = null; /** diff --git a/src/auth/provider-registry.ts b/src/auth/provider-registry.ts new file mode 100644 index 0000000..5d5d571 --- /dev/null +++ b/src/auth/provider-registry.ts @@ -0,0 +1,139 @@ +/** + * Provider registry — canonical list of supported LLM providers and their + * auth modes. + * + * v1 ships with exactly five providers (per spec S3). The `openai-compat` + * entry covers any OpenAI-API-compatible endpoint via `baseURL`. + * + * Hard constraints enforced here: + * #3 — `oauth_external` is local-only; `oauth_device_code` + `api_key` work anywhere. + * #4 — Anthropic `oauth_external` carries a billing warning. + * #5 — No implicit activation from env vars. + */ + +import { z } from "zod"; + +export const ProviderIdSchema = z.enum(["anthropic", "openai", "xai", "gemini", "openai-compat"]); +export type ProviderId = z.infer; + +export const AuthTypeSchema = z.enum(["api_key", "oauth_device_code", "oauth_external"]); +export type AuthType = z.infer; + +export const AuthSourceSchema = z.enum(["env", "strand_store", "external_cli"]); +export type AuthSource = z.infer; + +export const HostConstraintSchema = z.enum(["any", "local_only"]); +export type HostConstraint = z.infer; + +export interface AuthMode { + readonly type: AuthType; + readonly source: AuthSource; + readonly envKey?: string; + readonly hostConstraint: HostConstraint; + readonly billingWarning?: string; +} + +export interface ProviderDef { + readonly id: ProviderId; + readonly displayName: string; + readonly primaryAuth: AuthMode; + readonly secondaryAuth?: AuthMode; + readonly baseUrlEnv?: string; +} + +const ANTHROPIC_BILLING_WARNING = + "Anthropic routes third-party OAuth clients to the extra_usage billing pool, " + + "which is empty for most users. You may incur metered API charges on your " + + "Claude Pro/Max subscription. See hermes-agent issue #12905."; + +const PROVIDERS: readonly ProviderDef[] = [ + { + id: "anthropic", + displayName: "Anthropic", + primaryAuth: { + type: "api_key", + source: "env", + envKey: "ANTHROPIC_API_KEY", + hostConstraint: "any", + }, + secondaryAuth: { + type: "oauth_external", + source: "external_cli", + hostConstraint: "local_only", + billingWarning: ANTHROPIC_BILLING_WARNING, + }, + }, + { + id: "openai", + displayName: "OpenAI", + primaryAuth: { + type: "api_key", + source: "env", + envKey: "OPENAI_API_KEY", + hostConstraint: "any", + }, + secondaryAuth: { + type: "oauth_device_code", + source: "strand_store", + hostConstraint: "any", + }, + }, + { + id: "xai", + displayName: "xAI", + primaryAuth: { + type: "api_key", + source: "env", + envKey: "XAI_API_KEY", + hostConstraint: "any", + }, + }, + { + id: "gemini", + displayName: "Gemini", + primaryAuth: { + type: "api_key", + source: "env", + envKey: "GEMINI_API_KEY", + hostConstraint: "any", + }, + secondaryAuth: { + type: "oauth_external", + source: "external_cli", + hostConstraint: "local_only", + }, + }, + { + id: "openai-compat", + displayName: "OpenAI-compatible", + primaryAuth: { + type: "api_key", + source: "env", + envKey: "OPENAI_API_KEY", + hostConstraint: "any", + }, + baseUrlEnv: "OPENAI_BASE_URL", + }, +] as const; + +const REGISTRY = new Map(PROVIDERS.map((p) => [p.id, p])); + +export function listProviders(): readonly ProviderDef[] { + return PROVIDERS; +} + +export function getProvider(id: ProviderId): ProviderDef | undefined { + return REGISTRY.get(id); +} + +export function availableAuthModes(id: ProviderId): readonly AuthMode[] { + const def = REGISTRY.get(id); + if (!def) return []; + const modes: AuthMode[] = [def.primaryAuth]; + if (def.secondaryAuth) modes.push(def.secondaryAuth); + return modes; +} + +export function requiresBaseUrl(id: ProviderId): boolean { + return id === "openai-compat"; +} diff --git a/src/cli/commands/review.ts b/src/cli/commands/review.ts index ebe9290..4258463 100644 --- a/src/cli/commands/review.ts +++ b/src/cli/commands/review.ts @@ -254,4 +254,66 @@ export function registerReviewCmd(program: Command, _ctx: CliContext): void { } } }); + + // ─── Phase 2: `review gate-check` ───────────────────────────── + review + .command("gate-check") + .description("programmatic Phase 3 gate check — exits 0 if ready, 1 if not") + .option("--min-labeled ", "minimum labeled candidates", "100") + .option("--min-agreement ", "minimum agreement %", "80") + .option("--mode ", "filter by mode", "shadow") + .option("--json", "emit JSON result to stdout") + .action( + async (opts: { minLabeled: string; minAgreement: string; mode: string; json?: boolean }) => { + const { db } = await import("@/db"); + + const minLabeled = Number.parseInt(opts.minLabeled, 10) || 100; + const minAgreement = Number.parseInt(opts.minAgreement, 10) || 80; + + const rows = db() + .prepare( + `SELECT status, operator_label + FROM action_log + WHERE operator_label IS NOT NULL AND mode = ?`, + ) + .all(opts.mode) as Array<{ status: string; operator_label: string }>; + + const total = rows.length; + let agree = 0; + let disagree = 0; + + for (const r of rows) { + const policyApproved = r.status === "approved" || r.status === "executed"; + if (r.operator_label === "unclear") continue; + const operatorGood = r.operator_label === "good"; + if (policyApproved === operatorGood) { + agree++; + } else { + disagree++; + } + } + + const decisive = agree + disagree; + const agreementPct = decisive > 0 ? (agree / decisive) * 100 : 0; + const gateMet = total >= minLabeled && agreementPct >= minAgreement; + + if (opts.json) { + const result = { + ready: gateMet, + mode: opts.mode, + total_labeled: total, + min_labeled: minLabeled, + agreement_pct: Number(agreementPct.toFixed(2)), + min_agreement_pct: minAgreement, + }; + printLine(JSON.stringify(result, null, 2)); + } else { + printLine(gateMet ? "READY" : "NOT_READY"); + printLine(` labeled: ${total}/${minLabeled}`); + printLine(` agreement: ${agreementPct.toFixed(2)}% (min ${minAgreement}%)`); + } + + process.exit(gateMet ? 0 : 1); + }, + ); } diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index cb92639..9f44a71 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -7,10 +7,50 @@ export function registerStatusCmd(program: Command, _ctx: CliContext): void { .command("status") .description("orchestrator status + recent events / actions / reasoner / consolidator rows") .option("--json", "emit status as JSON for programmatic checks") - .action(async (opts: { json?: boolean }) => { + .option("--metrics", "show Phase 3 health metrics dashboard") + .action(async (opts: { json?: boolean; metrics?: boolean }) => { const { db } = await import("@/db"); const dbh = db(); + if (opts.metrics) { + // Phase 3: Health metrics dashboard + const { getHealthSummary } = await import("@/metrics"); + const metrics = getHealthSummary(); + + printLine("=== Phase 3 Health Metrics ==="); + printLine(""); + + printLine("--- X API Health (last hour) ---"); + if (metrics.xHealth.length === 0) { + printLine(" No health snapshots recorded yet"); + } else { + for (const h of metrics.xHealth.slice(0, 5)) { + printLine(` [${h.sampledAt}] ${h.endpoint}: ${h.healthy ? "healthy" : "degraded"}`); + } + } + printLine(""); + + printLine("--- Follower Delta ---"); + if (metrics.followerDelta) { + printLine(` Current: ${metrics.followerDelta.followersCount}`); + printLine(` 24h change: ${metrics.followerDelta.delta24h ?? 0}`); + printLine(` Last sampled: ${metrics.followerDelta.sampledAt}`); + } else { + printLine(" No follower data recorded yet"); + } + printLine(""); + + printLine("--- Error Rates (last 24h) ---"); + if (metrics.errorRates.length === 0) { + printLine(" No errors recorded"); + } else { + for (const e of metrics.errorRates.slice(0, 10)) { + printLine(` [${e.hourBucket}] ${e.kind}.${e.errorCode}: ${e.count}`); + } + } + return; + } + if (opts.json) { // JSON output for 48h sanity checks const eventCounts = dbh diff --git a/src/cli/commands/tui.ts b/src/cli/commands/tui.ts index 6a572bf..829ab77 100644 --- a/src/cli/commands/tui.ts +++ b/src/cli/commands/tui.ts @@ -15,4 +15,16 @@ export function registerTuiCmd(program: Command, _ctx: CliContext): void { pollMs: Number.isFinite(n) && n > 0 ? n : 2000, }); }); + + program + .command("cockpit") + .description("open the live Strand operator cockpit") + .option("--poll-ms ", "dashboard poll cadence in ms", "2000") + .action(async (opts: { pollMs: string }) => { + const n = Number(opts.pollMs); + await launchTui({ + dashboard: true, + pollMs: Number.isFinite(n) && n > 0 ? n : 2000, + }); + }); } diff --git a/src/cli/tui/components.tsx b/src/cli/tui/components.tsx index a273859..b00f9d2 100644 --- a/src/cli/tui/components.tsx +++ b/src/cli/tui/components.tsx @@ -1,33 +1,33 @@ /** * Stateless presentational components for the Strand TUI. * - * Every piece of data is passed in as props — no hook calls here, no side - * effects. Makes these trivial to render in tests with whatever mock data we - * want. + * Every visible row is sized before Ink sees it. That keeps the cockpit stable + * in 80-column terminals and avoids flex-row wrapping between adjacent Text + * nodes. */ import type { PlanStep, StepStatus, TaskGraph } from "@/agent/types"; import { Box, Text } from "ink"; -import Spinner from "ink-spinner"; -import type { ReactElement } from "react"; -import type { InvocationRow, RunSummary } from "./hooks"; +import type { ReactElement, ReactNode } from "react"; +import type { InvocationRow, OperatorSnapshot, RunSummary } from "./hooks"; +import { fit, kv, pad, panelInnerWidth, ratioBar, sign, truncate } from "./layout"; -// ─── Visual helpers ───────────────────────────────────────────────────────── +// --- Visual helpers --------------------------------------------------------- function statusGlyph(s: StepStatus): string { switch (s) { case "completed": - return "\u2713"; + return "ok"; case "running": - return "\u27F3"; + return ">>"; case "failed": - return "\u2717"; + return "!!"; case "skipped": - return "\u2192"; + return "--"; case "abandoned": - return "\u00D7"; + return "xx"; case "pending": - return "·"; + return ".."; } } @@ -49,75 +49,202 @@ function statusColor(s: StepStatus): string { } function shortId(id: string): string { - return id.length > 4 ? `${id.slice(0, 4)}\u2026` : id; + return id.length > 6 ? `${id.slice(0, 6)}...` : id; } function fmtTime(iso: string): string { try { const d = new Date(iso); + if (!Number.isFinite(d.getTime())) return iso.slice(11, 19) || iso; const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); const ss = String(d.getSeconds()).padStart(2, "0"); return `${hh}:${mm}:${ss}`; } catch { - return iso.slice(11, 19); + return iso.slice(11, 19) || iso; } } function fmtDuration(ms: number | null): string { - if (ms == null) return "\u2014"; + if (ms == null) return "-"; if (ms < 1000) return `${ms}ms`; return `${(ms / 1000).toFixed(1)}s`; } function fmtUsdFromTicks(ticks: number): string { - // 1 tick = 1e-10 USD. const usd = ticks / 1e10; if (usd === 0) return "$0.00"; if (usd < 0.01) return `$${usd.toFixed(4)}`; return `$${usd.toFixed(2)}`; } -// ─── Header ───────────────────────────────────────────────────────────────── +function fmtMinutes(minutes: number | null): string { + if (minutes == null) return "-"; + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + if (hours < 48) return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`; + return `${Math.floor(hours / 24)}d`; +} + +function fmtMaybeCount(n: number | null): string { + return n == null ? "-" : String(n); +} + +function fmtNumber(n: number): string { + return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} + +function panelColor(issueCount: number): string { + if (issueCount > 0) return "red"; + return "green"; +} + +function Panel({ + title, + width, + color = "gray", + children, +}: { + title: string; + width: number; + color?: string; + children: ReactNode; +}): ReactElement { + const inner = panelInnerWidth(width); + return ( + + + {fit(title, inner)} + + {children} + + ); +} + +function PanelLine({ + width, + color, + children, +}: { + width: number; + color?: string; + children: string; +}): ReactElement { + const line = fit(children, panelInnerWidth(width)); + if (color) return {line}; + return {line}; +} + +// --- Header ---------------------------------------------------------------- export interface HeaderProps { provider: string; model: string; mode: string; + halt: string; + tier: string; credentialStore: string; tenant: string | null; + width?: number; } export function Header(props: HeaderProps): ReactElement { + const width = props.width ?? 80; + const inner = Math.max(20, width - 2); + const modelBudget = Math.max(14, inner - 42); + const model = truncate(`${props.provider}/${props.model}`, modelBudget); + const halt = props.halt === "true" ? "HALTED" : "armed"; return ( - - - - Strand TUI - - — live agent harness - - - provider: - - {props.provider}/{props.model} - - mode: - - {props.mode} - - - - credential store: - {props.credentialStore} - tenant: - {props.tenant ?? "\u2014"} - + + + {fit("STRAND COCKPIT - live agent harness", inner)} + + + {fit(`model ${model} | mode ${props.mode} | halt ${halt} | tier ${props.tier}`, inner)} + + + {fit(`credential store ${props.credentialStore} | tenant ${props.tenant ?? "-"}`, inner)} + ); } -// ─── TaskGraphsPane ───────────────────────────────────────────────────────── +// --- Operator cockpit ------------------------------------------------------- + +export interface OperatorPaneProps { + snapshot: OperatorSnapshot; + loading: boolean; + width: number; +} + +function healthColor(row: OperatorSnapshot["x"]["latestHealth"][number]): string { + if (row.healthy === 0) return "red"; + if (row.remaining != null && row.limit != null && row.limit > 0) { + const ratio = row.remaining / row.limit; + if (ratio < 0.1) return "red"; + if (ratio < 0.25) return "yellow"; + } + return "green"; +} + +function healthText(row: OperatorSnapshot["x"]["latestHealth"][number]): string { + const state = healthColor(row) === "green" ? "ok" : healthColor(row); + return `${row.endpoint} ${fmtMaybeCount(row.remaining)}/${fmtMaybeCount(row.limit)} ${state}`; +} + +function actionsByKindText(rows: OperatorSnapshot["actions24h"]["byKind"]): string { + if (rows.length === 0) return "none"; + return rows.map((r) => `${r.kind}:${r.count}`).join(" "); +} + +export function OperatorPane({ snapshot, loading, width }: OperatorPaneProps): ReactElement { + const inner = panelInnerWidth(width); + const barWidth = Math.max(8, Math.min(18, Math.floor(inner / 5))); + const actionTotal = Math.max(1, snapshot.actions24h.total); + const executedBar = ratioBar(snapshot.actions24h.executed, actionTotal, barWidth); + const guardIssueCount = + snapshot.guardrails.dlqOpen + snapshot.actions24h.failed + snapshot.actions24h.rejected; + const usageBar = + snapshot.x.monthlyUsed == null || snapshot.x.monthlyCap == null + ? "[-]" + : ratioBar(snapshot.x.monthlyUsed, snapshot.x.monthlyCap, barWidth); + const monthly = + snapshot.x.monthlyUsed == null || snapshot.x.monthlyCap == null + ? "-" + : `${snapshot.x.monthlyUsed}/${snapshot.x.monthlyCap}`; + const followers = snapshot.followers + ? `${fmtNumber(snapshot.followers.count)} (${sign(snapshot.followers.delta24h)} 24h)` + : "-"; + const latestHealth = snapshot.x.latestHealth.slice(0, 3).map(healthText).join(" | "); + const title = loading ? "operator cockpit / syncing" : "operator cockpit"; + + return ( + + 0 ? "yellow" : "green"}> + {`MISSION ${kv("review", snapshot.review.open)} open | oldest ${fmtMinutes( + snapshot.review.oldestMinutes, + )} | actions ${snapshot.actions24h.total}`} + + 0 ? "red" : "cyan"}> + {`PULSE exec ${executedBar} ${snapshot.actions24h.executed}/${snapshot.actions24h.total} | approved ${snapshot.actions24h.approved} | kinds ${actionsByKindText( + snapshot.actions24h.byKind, + )}`} + + + {`SHIELD cooldowns ${snapshot.guardrails.activeCooldowns} | dlq ${snapshot.guardrails.dlqOpen} | dedup ${snapshot.guardrails.recentDuplicateHashes} | rejected ${snapshot.actions24h.rejected} | failed ${snapshot.actions24h.failed}`} + + + {`REACH x usage ${usageBar} ${monthly} | followers ${followers}`} + + + {`HEALTH ${latestHealth.length === 0 ? "no snapshots" : latestHealth}`} + + + ); +} + +// --- Task graphs ------------------------------------------------------------ export interface TaskGraphsPaneProps { graphs: TaskGraph[]; @@ -125,118 +252,96 @@ export interface TaskGraphsPaneProps { selectedIdx: number; expanded: boolean; focused: boolean; + width: number; } -function StepLine({ step }: { step: PlanStep }): ReactElement { - const glyph = statusGlyph(step.status); - const color = statusColor(step.status); +function stepLine(step: PlanStep, width: number): string { const duration = step.startedAt && step.completedAt ? fmtDuration(new Date(step.completedAt).getTime() - new Date(step.startedAt).getTime()) : step.startedAt ? fmtDuration(Date.now() - new Date(step.startedAt).getTime()) - : null; - return ( - - {glyph} - {step.status.padEnd(9, " ")} - {step.goal.slice(0, 48)} - {duration ? ({duration}) : null} - {step.error ? error: {step.error.slice(0, 32)} : null} - - ); + : "-"; + const prefix = `${statusGlyph(step.status)} ${pad(step.status, 9)} ${duration.padStart(8)} `; + const suffix = step.error ? ` | error ${step.error}` : ""; + return `${prefix}${truncate(step.goal, Math.max(12, width - prefix.length - suffix.length))}${suffix}`; } -function GraphLine({ g, selected }: { g: TaskGraph; selected: boolean }): ReactElement { +function graphLine(g: TaskGraph, selected: boolean, width: number): string { const total = g.steps.length; const done = g.steps.filter((s) => s.status === "completed").length; const running = g.steps.some((s) => s.status === "running"); const cursor = selected ? ">" : " "; - return ( - - {cursor} - {shortId(g.id)} - {g.status.padEnd(10, " ")} - "{g.rootGoal.slice(0, 42)}" - - {" "} - {done} / {total} steps - - {running ? ( - - {" "} - - - ) : null} - - ); + const prefix = `${cursor} ${shortId(g.id)} ${pad(g.status, 10)} `; + const suffix = ` ${done}/${total} steps${running ? " running" : ""}`; + return `${prefix}${truncate(g.rootGoal, Math.max(10, width - prefix.length - suffix.length))}${suffix}`; } export function TaskGraphsPane(props: TaskGraphsPaneProps): ReactElement { + const inner = panelInnerWidth(props.width); + const title = `active task graphs${props.focused ? " / focused" : ""}`; return ( - - - {"─── active task graphs "} - {props.focused ? "[focused]" : ""} - + {props.loading && props.graphs.length === 0 ? ( - - - loading… - - + + {"loading active graphs"} + ) : props.graphs.length === 0 ? ( - (no active graphs) + + {"(no active graphs)"} + ) : ( props.graphs.map((g, i) => ( - + + {graphLine(g, i === props.selectedIdx, inner)} + {props.expanded && i === props.selectedIdx - ? g.steps.map((s) => ) + ? g.steps.map((s) => ( + + {stepLine(s, inner)} + + )) : null} )) )} - + ); } -// ─── RunSummaryPane ───────────────────────────────────────────────────────── +// --- Run summary ------------------------------------------------------------ export interface RunSummaryPaneProps { summary: RunSummary; loading: boolean; + width: number; } export function RunSummaryPane(props: RunSummaryPaneProps): ReactElement { const r = props.summary.reasoner; const c = props.summary.consolidator; + const inner = panelInnerWidth(props.width); + const barWidth = Math.max(8, Math.min(18, Math.floor(inner / 5))); + const title = props.loading ? "run pulse 24h / syncing" : "run pulse 24h"; return ( - - {"─── recent runs (24h)"} - - reasoner: - {r.ticks} ticks · - {r.candidates} candidates · - {r.toolCalls} tool calls · - {fmtUsdFromTicks(r.costUsdTicks)} - - - consolidator: - {c.total} runs · - {c.completed} completed - · - {c.failed} failed - · - {c.inProgress} in-progress - · - {c.queued} queued - - + 0 ? "yellow" : "green"}> + + {`reasoner ${r.ticks} ticks | ${r.candidates} candidates | ${r.toolCalls} tool calls | ${fmtUsdFromTicks( + r.costUsdTicks, + )}`} + + 0 ? "yellow" : "green"}> + {`consolidator ${ratioBar(c.completed, Math.max(1, c.total), barWidth)} ${c.total} runs | ok ${c.completed} | fail ${c.failed} | wip ${c.inProgress} | queue ${c.queued}`} + + ); } -// ─── InvocationsPane ──────────────────────────────────────────────────────── +// --- Invocations ------------------------------------------------------------ export interface InvocationsPaneProps { rows: InvocationRow[]; @@ -244,6 +349,7 @@ export interface InvocationsPaneProps { focused: boolean; scrollOffset: number; maxRows?: number; + width: number; } export function InvocationsPane(props: InvocationsPaneProps): ReactElement { @@ -251,46 +357,99 @@ export function InvocationsPane(props: InvocationsPaneProps): ReactElement { const total = props.rows.length; const start = Math.min(Math.max(0, props.scrollOffset), Math.max(0, total - 1)); const visible = props.rows.slice(start, start + maxRows); + const inner = panelInnerWidth(props.width); + const title = `tool invocations${props.focused ? " / focused" : ""} (${visible.length}/${total})`; return ( - - - {"─── tool invocations "} - {props.focused ? "[focused] " : ""} - (showing {visible.length}/{total}) - + {total === 0 ? ( - (no invocations yet) + + {"(no invocations yet)"} + ) : ( - visible.map((r) => ( - - {fmtTime(r.at)} - {r.toolName.padEnd(16, " ")} - {fmtDuration(r.durationMs).padStart(8, " ")} - {r.error ? {r.error.slice(0, 40)} : null} - - )) + visible.map((r) => { + const prefix = `${fmtTime(r.at)} ${pad(truncate(r.toolName, 18), 18)} ${pad( + fmtDuration(r.durationMs), + 8, + )}`; + const error = r.error ? ` error ${r.error}` : ""; + return ( + + {fit(`${prefix}${truncate(error, Math.max(0, inner - prefix.length))}`, inner)} + + ); + }) )} - + ); } -// ─── Footer ───────────────────────────────────────────────────────────────── +// --- Help + footer ---------------------------------------------------------- + +export interface HelpEntry { + key: string; + description: string; +} + +export const HELP_ENTRIES: HelpEntry[] = [ + { key: "?", description: "toggle this help menu" }, + { key: "tab", description: "switch focus between graphs and tools" }, + { key: "up/down", description: "move graph selection or invocation scroll" }, + { key: "enter", description: "expand or collapse the selected graph" }, + { key: "r", description: "refresh every data panel once" }, + { key: "p", description: "pause or resume polling" }, + { key: "w", description: "return to the welcome screen" }, + { key: "q / ctrl-c", description: "quit Strand cockpit" }, + { key: "esc", description: "close help" }, +]; + +export interface HelpPanelProps { + width: number; + focusedPane: "graphs" | "invocations"; + paused: boolean; +} + +export function HelpPanel(props: HelpPanelProps): ReactElement { + return ( + + + {`state focus ${props.focusedPane} | polling ${props.paused ? "paused" : "live"}`} + + {HELP_ENTRIES.map((entry) => ( + + {`${pad(`[${entry.key}]`, 12)} ${entry.description}`} + + ))} + + ); +} export interface FooterProps { focusedPane: "graphs" | "invocations"; lastRefreshAt: number; + paused: boolean; + width: number; } export function Footer(props: FooterProps): ReactElement { + const inner = Math.max(20, props.width - 2); + const verb = props.paused ? "resume" : "pause"; + const focusHint = + props.focusedPane === "graphs" ? "[up/down] select [enter] expand" : "[up/down] scroll tools"; return ( - {"[↑↓] select · [enter] expand · [tab] switch pane ("} - {props.focusedPane} - {") · [r] refresh · [p] pause · [q] quit"} + {fit( + `[?] help [tab] focus ${props.focusedPane} [r] refresh [p] ${verb} [q] quit`, + inner, + )} + + + {fit( + `${focusHint} [w] welcome refreshed ${fmtTime(new Date(props.lastRefreshAt).toISOString())}`, + inner, + )} - last refresh: {fmtTime(new Date(props.lastRefreshAt).toISOString())} ); } diff --git a/src/cli/tui/dashboard.tsx b/src/cli/tui/dashboard.tsx index 43a255c..f4927f0 100644 --- a/src/cli/tui/dashboard.tsx +++ b/src/cli/tui/dashboard.tsx @@ -11,37 +11,51 @@ */ import { env } from "@/config"; -import { Box, Text, useApp, useInput, useStdin } from "ink"; +import { Box, Text, useApp, useInput, useStdin, useStdout } from "ink"; import type { ReactElement } from "react"; import { useCallback, useMemo, useState } from "react"; -import { Footer, Header, InvocationsPane, RunSummaryPane, TaskGraphsPane } from "./components"; -import { useRecentInvocations, useRunSummary, useTaskGraphs } from "./hooks"; +import { + Footer, + Header, + HelpPanel, + InvocationsPane, + OperatorPane, + RunSummaryPane, + TaskGraphsPane, +} from "./components"; +import { useOperatorSnapshot, useRecentInvocations, useRunSummary, useTaskGraphs } from "./hooks"; +import { splitWidths, terminalWidth } from "./layout"; export interface DashboardProps { pollMs?: number; onWelcome?: () => void; + width?: number; } -export function Dashboard({ pollMs = 2000, onWelcome }: DashboardProps): ReactElement { +export function Dashboard({ pollMs = 2000, onWelcome, width }: DashboardProps): ReactElement { const app = useApp(); const { isRawModeSupported } = useStdin(); + const { stdout } = useStdout(); const [selectedIdx, setSelectedIdx] = useState(0); const [expanded, setExpanded] = useState(true); const [focusedPane, setFocusedPane] = useState<"graphs" | "invocations">("graphs"); const [scrollOffset, setScrollOffset] = useState(0); const [lastRefreshAt, setLastRefreshAt] = useState(Date.now()); const [paused, setPaused] = useState(false); + const [showHelp, setShowHelp] = useState(false); const graphs = useTaskGraphs(paused ? 10 * 60_000 : pollMs); + const operator = useOperatorSnapshot(paused ? 10 * 60_000 : Math.max(pollMs, 3000)); const summary = useRunSummary(paused ? 10 * 60_000 : Math.max(pollMs * 2, 5000)); const invocations = useRecentInvocations(50, paused ? 10 * 60_000 : Math.max(pollMs / 2, 1000)); const refreshAll = useCallback((): void => { graphs.refresh(); + operator.refresh(); summary.refresh(); invocations.refresh(); setLastRefreshAt(Date.now()); - }, [graphs, summary, invocations]); + }, [graphs, operator, summary, invocations]); useInput( (input, key) => { @@ -49,6 +63,14 @@ export function Dashboard({ pollMs = 2000, onWelcome }: DashboardProps): ReactEl app.exit(); return; } + if (input === "?") { + setShowHelp((v) => !v); + return; + } + if (key.escape) { + if (showHelp) setShowHelp(false); + return; + } if (input === "w" && onWelcome) { onWelcome(); return; @@ -57,14 +79,17 @@ export function Dashboard({ pollMs = 2000, onWelcome }: DashboardProps): ReactEl refreshAll(); return; } - if (key.tab) { - setFocusedPane((p) => (p === "graphs" ? "invocations" : "graphs")); - return; - } if (input === "p") { setPaused((p) => !p); return; } + if (showHelp) { + return; + } + if (key.tab) { + setFocusedPane((p) => (p === "graphs" ? "invocations" : "graphs")); + return; + } if (focusedPane === "graphs") { if (key.upArrow) { setSelectedIdx((i) => Math.max(0, i - 1)); @@ -97,15 +122,20 @@ export function Dashboard({ pollMs = 2000, onWelcome }: DashboardProps): ReactEl provider: env.LLM_PROVIDER, model: env.LLM_MODEL_REASONER, mode: env.STRAND_MODE, + halt: env.STRAND_HALT, + tier: env.TIER, credentialStore: process.env["STRAND_CREDENTIAL_STORE"] ?? "env", tenant: process.env["STRAND_TENANT"] ?? null, }), [], ); + const viewportWidth = terminalWidth(width ?? stdout.columns); + const layout = splitWidths(viewportWidth); + return ( -
+
{!isRawModeSupported ? ( @@ -118,21 +148,60 @@ export function Dashboard({ pollMs = 2000, onWelcome }: DashboardProps): ReactEl {"[paused] — press p to resume, r to refresh once"} ) : null} - - - + ) : ( + <> + + {layout.stacked ? ( + <> + + + + ) : ( + + + + + + )} + + + )} +