Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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<void> {
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.
78 changes: 78 additions & 0 deletions docs/research/2026-05-15-riven-cursor-terminal-loop-design.md
Original file line number Diff line number Diff line change
@@ -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.
156 changes: 156 additions & 0 deletions tools/riven/riven-cursor-terminal-loop.ts
Original file line number Diff line number Diff line change
@@ -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));
}
Comment on lines +41 to +43

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" },
});
Comment on lines +53 to +58
} catch {
// Best effort; terminal output is the source of truth
}
}

async function runAgentGate(): Promise<void> {
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.",
Comment on lines +70 to +79
].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<void> {
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.");
}
Comment on lines +115 to +122
} 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);
});
Comment on lines +139 to +144

// 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);
});
Loading