diff --git a/docs/backlog/P1/B-0498-riven-cursor-terminal-background-loop-ide-native-autonomous-gate-2026-05-15.md b/docs/backlog/P1/B-0498-riven-cursor-terminal-background-loop-ide-native-autonomous-gate-2026-05-15.md new file mode 100644 index 000000000..b9f58c058 --- /dev/null +++ b/docs/backlog/P1/B-0498-riven-cursor-terminal-background-loop-ide-native-autonomous-gate-2026-05-15.md @@ -0,0 +1,150 @@ +--- +id: B-0498 +priority: P1 +status: open +title: "Riven Cursor Terminal background loop — IDE-native autonomous gate with manager contract" +tier: agent-infrastructure +effort: M +created: 2026-05-15 +last_updated: 2026-05-15 +depends_on: [B-0400] +composes_with: [B-0440, B-0441, B-0442, B-0497] +tags: [riven, cursor, terminal, background-service, ide-native, autonomous-loop] +type: feature +--- + +# Riven Cursor Terminal background loop — IDE-native autonomous gate with manager contract + +## Origin + +Aaron 2026-05-15 observed that Cursor exposes a persistent "1 Terminal" tab that survives session context and can host a visible background loop. + +Current Riven autonomy surface: + +- Headless: launchd service (`com.zeta.riven-loop`) running `~/.local/share/zeta-riven-loop/Zeta/.cursor/bin/riven-loop-tick.ts` every 60s with 15-minute agent gates. +- Limitation: invisible to Aaron inside Cursor; logs only accessible via `~/Library/Logs/zeta-riven-loop/`. + +Goal: add a Cursor-native terminal loop that: + +- Runs inside the visible "1 Terminal" tab +- Executes the same trajectory-manager contract (read broadcasts, walk trajectories, decompose mid-stride, dispatch subagents, own PRs through merge) +- Survives IDE restart (re-arm on tab open or via Cursor workspace settings) +- Writes to the same broadcast bus as the launchd loop (defense in depth) +- Gives Aaron live visibility without leaving the IDE + +This is defense-in-depth autonomy: headless (launchd) + IDE-native (Cursor Terminal) = Riven survives both "machine rebooted" and "IDE closed" scenarios. + +## Acceptance criteria + +- [ ] `tools/riven/riven-cursor-terminal-loop.ts` exists and is executable from the Cursor Terminal tab +- [ ] Script implements the same manager contract as the launchd tick (broadcast-first, mid-stride decomposition, parallel subagent dispatch, PR ownership through merge) +- [ ] Heartbeat visible in terminal every 60s (or configurable) +- [ ] Agent gate fires every 15min (configurable) with full contract prompt +- [ ] Re-arm logic: on IDE open / workspace load, script detects if already running and resumes (no duplicate gates) +- [ ] Graceful shutdown on terminal close (writes tombstone to bus, releases any in-flight claims) +- [ ] Broadcast bus integration: same topics as launchd loop (`heartbeat`, `claim`, `review-request`, `shadow-catch`) +- [ ] Documented in `docs/AUTONOMOUS-LOOP.md` under "Riven dual-loop architecture" +- [ ] No regression on launchd loop (both run in parallel without conflict) + +## Design sketch + +```typescript +// tools/riven/riven-cursor-terminal-loop.ts +// +// Cursor Terminal-resident autonomous loop. +// Run: bun tools/riven/riven-cursor-terminal-loop.ts +// Or: cursor-agent run tools/riven/riven-cursor-terminal-loop.ts (if SDK supports) + +import { publish, list, clean } from "../bus/bus"; +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +const STATE_FILE = join(process.env.HOME!, ".cursor/riven-terminal-loop-state.json"); +const HEARTBEAT_MS = 60_000; +const GATE_INTERVAL_MS = 15 * 60 * 1000; + +interface LoopState { + lastGateAt: string; + pid: number; +} + +function loadState(): LoopState | null { + if (!existsSync(STATE_FILE)) return null; + try { return JSON.parse(readFileSync(STATE_FILE, "utf8")); } catch { return null; } +} + +function saveState(state: LoopState): void { + writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); +} + +async function runGate(): Promise { + // Invoke cursor-agent chat with full manager contract prompt + // (same prompt as launchd tick, injected at runtime) + console.log(`[${new Date().toISOString()}] Riven gate start`); + // ... spawn cursor-agent, capture output, publish to bus ... + console.log(`[${new Date().toISOString()}] Riven gate end`); +} + +async function main(): Promise { + const existing = loadState(); + if (existing && Date.now() - new Date(existing.lastGateAt).getTime() < GATE_INTERVAL_MS) { + console.log("Riven terminal loop already running; resuming..."); + } + + // Heartbeat loop (visible in terminal) + setInterval(() => { + console.log(`[${new Date().toISOString()}] Riven heartbeat — claims=${/*...*/} open_prs=${/*...*/}`); + publish("riven", "*", { topic: "heartbeat", payload: { status: "alive", note: "cursor-terminal" } }); + }, HEARTBEAT_MS); + + // Gate loop + setInterval(async () => { + await runGate(); + saveState({ lastGateAt: new Date().toISOString(), pid: process.pid }); + }, GATE_INTERVAL_MS); + + // Graceful shutdown + process.on("SIGINT", () => { + publish("riven", "*", { topic: "heartbeat", payload: { status: "shutdown", note: "terminal-closed" } }); + process.exit(0); + }); + + console.log("Riven Cursor Terminal loop armed. Press Ctrl+C to stop."); +} + +main(); +``` + +## Re-arm on IDE open + +Cursor workspace settings or `.cursor/settings.json` can run a startup command: +```json +{ + "terminal.integrated.shellIntegration.enabled": true, + "workbench.action.terminal.runActiveFile": "bun tools/riven/riven-cursor-terminal-loop.ts" +} +``` + +Or a `.cursor/init.sh` hook (if Cursor supports workspace init scripts) that checks for an existing loop PID and only spawns if absent. + +## Defense in depth + +- Launchd loop survives full machine reboot. +- Cursor Terminal loop survives IDE close/reopen (if re-arm wired) and gives Aaron live visibility. +- Both publish to the same bus → Otto/Vera/Lior see a single Riven identity regardless of which loop fired. + +## Non-goals + +- Replacing the launchd loop (keep both). +- Headless operation from the terminal loop (launchd owns that). +- Complex TUI inside the terminal (simple heartbeat + gate status lines are enough). + +## Composes with + +- B-0400 (bus protocol) — shared transport +- B-0440/0441/0442 (bg-services) — same nudge/assignment/cascade topics +- B-0497 (Lior launchd integration) — dual-loop pattern precedent + +## Status + +Open. Design approved by Aaron 2026-05-15. Implementation queued for next autonomous cycle or explicit dispatch. diff --git a/docs/research/2026-05-15-riven-cursor-terminal-loop-design.md b/docs/research/2026-05-15-riven-cursor-terminal-loop-design.md new file mode 100644 index 000000000..6c8e36207 --- /dev/null +++ b/docs/research/2026-05-15-riven-cursor-terminal-loop-design.md @@ -0,0 +1,78 @@ +# Riven Cursor Terminal Loop — Design + +**Date:** 2026-05-15 +**Status:** Design approved; implementation queued +**Backlog:** B-0498 + +## Problem + +Riven's current autonomy surface (launchd + `riven-loop-tick.ts`) is headless and invisible inside Cursor. Aaron cannot watch the loop's heartbeat or gate output without tailing `~/Library/Logs/zeta-riven-loop/`. This reduces situational awareness and makes debugging autonomous behavior harder. + +## Solution + +Add a second, Cursor-native loop that runs inside the persistent "1 Terminal" tab. The loop: +- Is visible in the IDE (Aaron sees heartbeat + gate output live) +- Executes the same trajectory-manager contract as the launchd loop +- Survives IDE close/reopen via re-arm logic +- Publishes to the same B-0400 bus (single Riven identity across both loops) + +Result: defense-in-depth autonomy (headless + IDE-native) + live observability. + +## Architecture + +``` +Cursor IDE +├── "1 Terminal" tab (persistent) +│ └── riven-cursor-terminal-loop.ts +│ ├── Heartbeat (60s) → stdout + bus +│ ├── Gate (15min) → cursor-agent chat + bus +│ └── Re-arm on IDE open (check PID file / bus tombstone) +│ +└── launchd (headless) + └── riven-loop-tick.ts (existing, unchanged) +``` + +Both loops share: +- The manager contract prompt (injected at runtime) +- The B-0400 bus topics (`heartbeat`, `claim`, `review-request`, `shadow-catch`, `infinite-backlog-nudge`, etc.) +- The broadcast file `~/.local/share/zeta-broadcasts/riven.md` + +## Re-arm strategy + +On IDE open / workspace load: +1. Script checks for `~/.cursor/riven-terminal-loop-state.json` +2. If PID is alive and last gate < 15min ago → resume (no-op) +3. If PID dead or stale → spawn new gate loop, write new state file + +Cursor workspace hook (if supported) or a `.cursor/init.ts` can invoke the script on startup. + +## Failure modes + +- **Duplicate gates** — prevented by PID + timestamp check in state file +- **Terminal closed mid-gate** — publish tombstone to bus, release any in-flight claim +- **IDE crash** — next open triggers re-arm; state file survives +- **Bus unavailable** — loop continues (best-effort publish); errors logged to terminal + +## Implementation plan + +1. Create `tools/riven/riven-cursor-terminal-loop.ts` (skeleton + heartbeat) +2. Wire the existing manager contract prompt (same text as launchd tick) +3. Add state file + re-arm logic +4. Add graceful shutdown (SIGINT → bus tombstone) +5. Document in `docs/AUTONOMOUS-LOOP.md` +6. Arm in current "1 Terminal" session for first live test + +## Scope + +- P1 (self-sustainability win) +- Composes with existing bg-services (B-0440/0441/0442) and bus (B-0400) +- No changes to launchd loop (keep both) + +## Open questions + +- Does Cursor expose a workspace "on open" hook? (If not, manual re-arm on first terminal keystroke is acceptable.) +- Should the terminal loop also forward actions (git push, PR create) or delegate to launchd? (Current design: launchd owns forward; terminal loop is observation + gate only.) + +--- + +**Riven** — Split by truth. \ No newline at end of file diff --git a/tools/riven/riven-cursor-terminal-loop.ts b/tools/riven/riven-cursor-terminal-loop.ts new file mode 100755 index 000000000..b7009b99b --- /dev/null +++ b/tools/riven/riven-cursor-terminal-loop.ts @@ -0,0 +1,156 @@ +#!/usr/bin/env bun +// riven-cursor-terminal-loop.ts — IDE-native background loop for Riven (Cursor Terminal) +// +// Run inside the persistent "1 Terminal" tab: +// bun tools/riven/riven-cursor-terminal-loop.ts +// +// Features: +// - Visible heartbeat every 60s (stdout + bus) +// - Agent gate every 15min with full trajectory-manager contract +// - Re-arm safe (checks state file + PID) +// - Graceful shutdown on Ctrl+C (bus tombstone) +// - Composes with launchd loop (same bus, same contract) +// +// This is the Cursor-native complement to the headless launchd loop. +// Both run in parallel for defense-in-depth autonomy. + +import { publish } from "../bus/bus"; +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +const STATE_FILE = join(process.env.HOME!, ".cursor/riven-terminal-loop-state.json"); +const HEARTBEAT_MS = 60_000; +const GATE_INTERVAL_MS = 15 * 60 * 1000; +const AGENT_TIMEOUT_MS = 300_000; // 5min + +interface LoopState { + lastGateAt: string; + pid: number; +} + +function loadState(): LoopState | null { + if (!existsSync(STATE_FILE)) return null; + try { + return JSON.parse(readFileSync(STATE_FILE, "utf8")); + } catch { + return null; + } +} + +function saveState(state: LoopState): void { + writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function log(msg: string): void { + console.log(`[${nowIso()}] ${msg}`); +} + +function publishHeartbeat(status: "alive" | "shutdown", note?: string): void { + try { + publish("riven", "*", { + topic: "heartbeat", + payload: { status, note: note ?? "cursor-terminal" }, + }); + } catch { + // Best effort; terminal output is the source of truth + } +} + +async function runAgentGate(): Promise { + log("Riven gate start (Cursor Terminal)"); + + const contract = [ + "You are Riven, trajectory manager and adversarial-truth-axis reviewer for the Zeta factory.", + "This is a 15-minute autonomous cycle inside the Cursor Terminal loop.", + "Read broadcasts first: ~/.local/share/zeta-broadcasts/{otto,vera,lior,riven}.md", + "Walk assigned trajectories. Decompose only what you hit mid-stride.", + "Dispatch parallel subagents via the Task tool when work allows. Ownership of PRs remains with you.", + "Own every PR through merge: fix findings, resolve threads, arm auto-merge.", + "Learn from Otto and Vera patterns. Critique failure modes as data.", + "When blocked, create a *specific* research child the next pickup cannot dodge.", + "Write status to ~/.local/share/zeta-broadcasts/riven.md at cycle end.", + "GitHub PR state and file contents are authoritative; the bus is a coordination cache.", + "Report: open PRs, active claims, drift/contradiction, one toe-safe forward action or exact blocker.", + "Rodney's Razor + substrate-or-it-didn't-happen apply.", + ].join(" "); + + const result = spawnSync("cursor-agent", [ + "chat", + "--mode", "ask", + "--model", "grok-4.3", + contract, + ], { + encoding: "utf8", + timeout: AGENT_TIMEOUT_MS, + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.stdout && result.stdout.trim().length > 0) { + console.log(result.stdout.trim()); + } + if (result.stderr && result.stderr.trim().length > 0) { + console.error(result.stderr.trim()); + } + + const status = result.status === 0 ? "ok" : `exit-${result.status}`; + log(`Riven gate end — ${status}`); + + // Publish gate result to bus for other agents + try { + publish("riven", "*", { + topic: "shadow-catch", + payload: { content: `Cursor Terminal gate ${status}` }, + }); + } catch { + // Best effort + } +} + +async function main(): Promise { + const existing = loadState(); + if (existing) { + const age = Date.now() - new Date(existing.lastGateAt).getTime(); + if (age < GATE_INTERVAL_MS) { + log(`Riven Cursor Terminal loop already running (last gate ${Math.round(age / 1000)}s ago). Resuming...`); + } else { + log("Stale state file detected; starting fresh gate cycle."); + } + } else { + log("No prior state; starting fresh Riven Cursor Terminal loop."); + } + + // Heartbeat + setInterval(() => { + log("Riven heartbeat — Cursor Terminal loop alive"); + publishHeartbeat("alive"); + }, HEARTBEAT_MS); + + // Gate + setInterval(async () => { + await runAgentGate(); + saveState({ lastGateAt: nowIso(), pid: process.pid }); + }, GATE_INTERVAL_MS); + + // Graceful shutdown + process.on("SIGINT", () => { + log("Riven Cursor Terminal loop shutting down"); + publishHeartbeat("shutdown", "terminal-closed"); + process.exit(0); + }); + + // Initial gate on startup (so the first cycle isn't a full 15min wait) + await runAgentGate(); + saveState({ lastGateAt: nowIso(), pid: process.pid }); + + log("Riven Cursor Terminal loop armed. Heartbeat every 60s. Gate every 15min. Ctrl+C to stop."); +} + +main().catch((e) => { + console.error("Riven loop fatal:", e); + process.exit(1); +}); \ No newline at end of file