From 6fe6868608952b7d10ca6a0547b05d43c7edf982 Mon Sep 17 00:00:00 2001 From: Tao Feng Date: Wed, 18 Mar 2026 01:24:44 -0700 Subject: [PATCH] feat: nexus-backed stores for CLI/server + TUI puppet server + agent runner When grove.json declares mode "nexus", CLI commands (contribute, review) and the HTTP server now use NexusContributionStore/NexusClaimStore instead of local SQLite. This ensures all data flows to Nexus VFS at /zones/default/. Changes: - context.ts: initCliDeps reads grove.json, returns nexus stores when mode=nexus - contribute.ts: uses initCliDeps instead of direct SQLite - review.ts: uses initCliDeps instead of direct createSqliteStores - serve.ts: creates nexus stores alongside local runtime when mode=nexus - resolve-backend.ts: checkNexusHealth includes NEXUS_API_KEY auth header - up.ts: GROVE_PUPPET_PORT env enables puppet mode for TUI automation - puppet-server.ts: HTTP server for programmatic TUI control via pty - grove-agent.sh: agent runner script for acpx codex (coder/reviewer roles) --- scripts/grove-agent.sh | 85 +++++++++++ src/cli/commands/contribute.ts | 28 +--- src/cli/commands/review.ts | 21 +-- src/cli/commands/up.ts | 8 ++ src/cli/context.ts | 56 +++++++- src/cli/presets/review-loop.ts | 4 +- src/nexus/nexus-http-client.ts | 2 +- src/server/serve.ts | 61 ++++++-- src/shared/service-lifecycle.ts | 12 +- src/tui/app.tsx | 6 +- src/tui/puppet-server.ts | 241 ++++++++++++++++++++++++++++++++ src/tui/resolve-backend.ts | 5 +- src/tui/spawn-manager.ts | 11 +- 13 files changed, 483 insertions(+), 57 deletions(-) create mode 100755 scripts/grove-agent.sh create mode 100644 src/tui/puppet-server.ts diff --git a/scripts/grove-agent.sh b/scripts/grove-agent.sh new file mode 100755 index 00000000..141cf2a6 --- /dev/null +++ b/scripts/grove-agent.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Grove agent runner — spawned by TUI's Ctrl+P command palette. +# Usage: grove-agent.sh [round] +# +# Reads GROVE.md for context, constructs a role-specific prompt, +# and runs acpx codex to execute the agent's task autonomously. + +set -euo pipefail + +ROLE="${1:?Usage: grove-agent.sh }" +ROUND="${2:-1}" +GROVE_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +GROVE_BIN="bun $GROVE_ROOT/dist/cli/main.js" + +# Resolve nexus credentials from environment (set by grove up) +export NEXUS_API_KEY="${NEXUS_API_KEY:-}" +export GROVE_AGENT_ID="${ROLE}-$$" +export GROVE_AGENT_NAME="$(echo "$ROLE" | awk '{print toupper(substr($0,1,1)) substr($0,2)}')" + +echo "=== Grove Agent: $ROLE (round $ROUND) ===" +echo " grove root: $GROVE_ROOT" +echo " agent id: $GROVE_AGENT_ID" + +# Read current frontier to know what's available +FRONTIER=$($GROVE_BIN frontier --json 2>/dev/null || echo '[]') +LOG=$($GROVE_BIN log --limit 5 2>/dev/null || echo 'No contributions yet') + +# Build role-specific prompt +case "$ROLE" in + coder) + PROMPT="You are the CODER agent in a Grove review-loop. Working directory: $(pwd) + +CONTEXT — Recent contributions: +$LOG + +YOUR TASK (round $ROUND): +1. Read GROVE.md to understand the project +2. Create or improve a source file. If this is round 1, create src/utils/parser.ts with a simple CSV parser. If a later round, read previous reviews via 'grove log' and improve the code. +3. Submit your work by running: + NEXUS_API_KEY=$NEXUS_API_KEY GROVE_AGENT_ID=$GROVE_AGENT_ID GROVE_AGENT_NAME=$GROVE_AGENT_NAME $GROVE_BIN contribute --kind work --summary '' --mode evaluation --artifacts +4. Print the blake3 CID. + +IMPORTANT: Only create/edit files and run the grove contribute command. Nothing else." + ;; + + reviewer) + # Find the latest work contribution to review + LATEST_WORK=$(echo "$LOG" | grep -o 'blake3:[a-f0-9]*' | head -1) + PROMPT="You are the REVIEWER agent in a Grove review-loop. Working directory: $(pwd) + +CONTEXT — Recent contributions: +$LOG + +YOUR TASK: +1. Find source files to review (look in src/ directory) +2. Read the code carefully +3. Write a 2-3 sentence code review identifying issues +4. Submit your review by running: + NEXUS_API_KEY=$NEXUS_API_KEY GROVE_AGENT_ID=$GROVE_AGENT_ID GROVE_AGENT_NAME=$GROVE_AGENT_NAME $GROVE_BIN review ${LATEST_WORK:-HEAD} --summary '' --score quality=<0.0-1.0> +5. Print the blake3 CID. + +Score guide: 0.0-0.3 = poor, 0.4-0.6 = needs work, 0.7-0.8 = good, 0.9-1.0 = excellent. +IMPORTANT: Only read files and run the grove review command. Nothing else." + ;; + + *) + echo "Unknown role: $ROLE (expected: coder, reviewer)" + exit 1 + ;; +esac + +echo " prompt length: ${#PROMPT} chars" +echo " launching acpx codex..." +echo "" + +# Run the agent via acpx codex +npx acpx \ + --approve-all \ + --max-turns 8 \ + --timeout 180 \ + --format text \ + codex exec "$PROMPT" + +echo "" +echo "=== Agent $ROLE finished ===" diff --git a/src/cli/commands/contribute.ts b/src/cli/commands/contribute.ts index 839beebd..03652a83 100644 --- a/src/cli/commands/contribute.ts +++ b/src/cli/commands/contribute.ts @@ -277,30 +277,14 @@ export async function executeContribute(options: ContributeOptions): Promise<{ c throw new Error(`Invalid contribute options:\n ${validation.errors.join("\n ")}`); } - // 2. Find .grove/ - const grovePath = join(options.cwd, ".grove"); - try { - await access(grovePath); - } catch { - throw new Error("No grove found. Run 'grove init' first to create a grove in this directory."); - } - - // Dynamic imports for lazy loading - const { SqliteContributionStore, SqliteClaimStore, initSqliteDb } = await import( - "../../local/sqlite-store.js" - ); - const { FsCas } = await import("../../local/fs-cas.js"); - const { DefaultFrontierCalculator } = await import("../../core/frontier.js"); + // 2. Find .grove/ and initialize stores (nexus or SQLite depending on grove.json) + const { initCliDeps } = await import("../context.js"); const { parseGroveContract } = await import("../../core/contract.js"); const { EnforcingContributionStore } = await import("../../core/enforcing-store.js"); - const dbPath = join(grovePath, "grove.db"); - const casPath = join(grovePath, "cas"); - const db = initSqliteDb(dbPath); - const rawStore = new SqliteContributionStore(db); - const claimStore = new SqliteClaimStore(db); - const cas = new FsCas(casPath); - const frontier = new DefaultFrontierCalculator(rawStore); + const deps = initCliDeps(options.cwd); + const { claimStore, cas, frontier } = deps; + const rawStore = deps.store; // 3. Load GROVE.md contract for enforcement, default mode, and metric directions const grovemdPath = join(options.cwd, "GROVE.md"); @@ -479,7 +463,7 @@ export async function executeContribute(options: ContributeOptions): Promise<{ c return { cid: value.cid }; } finally { - db.close(); + deps.close(); } } diff --git a/src/cli/commands/review.ts b/src/cli/commands/review.ts index 801a3504..c2407a4e 100644 --- a/src/cli/commands/review.ts +++ b/src/cli/commands/review.ts @@ -8,15 +8,12 @@ */ import { parseArgs } from "node:util"; -import { DefaultFrontierCalculator } from "../../core/frontier.js"; import type { Score } from "../../core/models.js"; import type { OperationDeps } from "../../core/operations/deps.js"; import { reviewOperation } from "../../core/operations/index.js"; -import { FsCas } from "../../local/fs-cas.js"; -import { createSqliteStores } from "../../local/sqlite-store.js"; import { resolveAgent } from "../agent.js"; +import { initCliDeps } from "../context.js"; import { outputJson, outputJsonError } from "../format.js"; -import { resolveGroveDir } from "../utils/grove-dir.js"; export interface ReviewOptions { readonly targetCid: string; @@ -87,18 +84,14 @@ export function parseReviewArgs(args: readonly string[]): ReviewOptions { /** Execute `grove review` using the operations layer. */ export async function runReview(options: ReviewOptions, groveOverride?: string): Promise { - const { dbPath, groveDir } = resolveGroveDir(groveOverride); - const stores = createSqliteStores(dbPath); - const cas = new FsCas(`${groveDir}/cas`); - const frontier = new DefaultFrontierCalculator(stores.contributionStore); - + const deps = initCliDeps(process.cwd(), groveOverride); try { const agent = resolveAgent(); const opDeps: OperationDeps = { - contributionStore: stores.contributionStore, - claimStore: stores.claimStore, - cas, - frontier, + contributionStore: deps.store, + claimStore: deps.claimStore, + cas: deps.cas, + frontier: deps.frontier, }; const result = await reviewOperation( @@ -131,7 +124,7 @@ export async function runReview(options: ReviewOptions, groveOverride?: string): console.log(` Summary: ${result.value.summary}`); } } finally { - stores.close(); + deps.close(); } } diff --git a/src/cli/commands/up.ts b/src/cli/commands/up.ts index f2cdc434..4ed1a57b 100644 --- a/src/cli/commands/up.ts +++ b/src/cli/commands/up.ts @@ -82,6 +82,14 @@ export async function handleUp(args: readonly string[], groveOverride?: string): // Non-headless: always delegate to TUI — it shows the setup screen first, // then handles service startup internally with progress feedback. if (!opts.headless && !opts.noTui) { + // Puppet mode: run TUI in a pty subprocess with HTTP control API + if (process.env.GROVE_PUPPET_PORT) { + const { startPuppetAndServe } = await import("../../tui/puppet-server.js"); + const tuiArgs = ["dist/cli/main.js", "up"]; + if (effectiveGrove) tuiArgs.push("--grove", effectiveGrove); + await startPuppetAndServe(tuiArgs, Number(process.env.GROVE_PUPPET_PORT)); + return; + } const { handleTui } = await import("../../tui/main.js"); await handleTui([], effectiveGrove, { build: opts.build, nexusSource: opts.nexusSource }); return; diff --git a/src/cli/context.ts b/src/cli/context.ts index bc9046e8..42bb0996 100644 --- a/src/cli/context.ts +++ b/src/cli/context.ts @@ -6,14 +6,21 @@ * CLI commands. */ -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; +import type { ContentStore } from "../core/cas.js"; +import { parseGroveConfig } from "../core/config.js"; import type { FrontierCalculator } from "../core/frontier.js"; +import { DefaultFrontierCalculator } from "../core/frontier.js"; import type { OutcomeStore } from "../core/outcome.js"; import type { ClaimStore, ContributionStore } from "../core/store.js"; import type { WorkspaceManager } from "../core/workspace.js"; -import type { FsCas } from "../local/fs-cas.js"; import { createLocalRuntime } from "../local/runtime.js"; +import { NexusCas } from "../nexus/nexus-cas.js"; +import { NexusClaimStore } from "../nexus/nexus-claim-store.js"; +import { NexusContributionStore } from "../nexus/nexus-contribution-store.js"; +import { NexusHttpClient } from "../nexus/nexus-http-client.js"; +import { NexusOutcomeStore } from "../nexus/nexus-outcome-store.js"; const GROVE_DIR = ".grove"; @@ -23,7 +30,7 @@ export interface CliDeps { readonly claimStore: ClaimStore; readonly frontier: FrontierCalculator; readonly workspace: WorkspaceManager; - readonly cas: FsCas; + readonly cas: ContentStore; readonly groveRoot: string; readonly outcomeStore?: OutcomeStore | undefined; readonly close: () => void; @@ -71,9 +78,50 @@ export function initCliDeps(cwd: string, groveOverride?: string): CliDeps { ); } + // Use nexus-backed stores when grove.json declares mode "nexus" + const configPath = join(groveDir, "grove.json"); + if (existsSync(configPath)) { + try { + const groveConfig = parseGroveConfig(readFileSync(configPath, "utf-8")); + if (groveConfig.mode === "nexus" && groveConfig.nexusUrl) { + const apiKey = process.env.NEXUS_API_KEY || undefined; + const client = new NexusHttpClient({ url: groveConfig.nexusUrl, apiKey }); + const nexusConfig = { client, zoneId: "default" }; + + const store = new NexusContributionStore(nexusConfig); + const claimStore = new NexusClaimStore(nexusConfig); + const outcomeStore = new NexusOutcomeStore(nexusConfig); + const cas = new NexusCas(nexusConfig); + const frontier = new DefaultFrontierCalculator(store); + + // Local runtime only for workspace manager (needs SQLite for tracking) + const runtime = createLocalRuntime({ groveDir, frontierCacheTtlMs: 0, workspace: true }); + + return { + store, + claimStore, + frontier, + workspace: + runtime.workspace ?? + (() => { + throw new Error("Workspace manager failed"); + })(), + cas, + groveRoot: resolve(groveDir, ".."), + outcomeStore, + close: () => { + runtime.close(); + }, + }; + } + } catch { + // Config parse failed — fall through to local stores + } + } + const runtime = createLocalRuntime({ groveDir, - frontierCacheTtlMs: 0, // CLI commands are single-shot; no caching needed + frontierCacheTtlMs: 0, workspace: true, }); diff --git a/src/cli/presets/review-loop.ts b/src/cli/presets/review-loop.ts index 1c137598..d4d7872e 100644 --- a/src/cli/presets/review-loop.ts +++ b/src/cli/presets/review-loop.ts @@ -19,14 +19,14 @@ export const reviewLoopPreset: PresetConfig = { description: "Writes and iterates on code", maxInstances: 1, edges: [{ target: "reviewer", edgeType: "delegates" }], - command: "claude --dangerously-skip-permissions", + command: "scripts/grove-agent.sh coder", }, { name: "reviewer", description: "Reviews code and provides feedback", maxInstances: 1, edges: [{ target: "coder", edgeType: "feedback" }], - command: "claude --dangerously-skip-permissions", + command: "scripts/grove-agent.sh reviewer", }, ], spawning: { dynamic: true, maxDepth: 2 }, diff --git a/src/nexus/nexus-http-client.ts b/src/nexus/nexus-http-client.ts index 5ecef576..4be52f6f 100644 --- a/src/nexus/nexus-http-client.ts +++ b/src/nexus/nexus-http-client.ts @@ -68,7 +68,7 @@ const ReadResultSchema = z.union([BytesResultSchema, LegacyReadResultSchema]); const WriteResultSchema = z .object({ bytes_written: z.number(), - etag: z.string(), + etag: z.string().optional().default(""), version: z.number().optional(), }) .passthrough(); diff --git a/src/server/serve.ts b/src/server/serve.ts index bef67cd1..500865e3 100644 --- a/src/server/serve.ts +++ b/src/server/serve.ts @@ -7,11 +7,21 @@ * This is the only file excluded from test coverage — use createApp() for testing. */ +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { parseGroveConfig } from "../core/config.js"; +import { DefaultFrontierCalculator } from "../core/frontier.js"; import type { GossipService } from "../core/gossip/types.js"; +import { CachedFrontierCalculator } from "../gossip/cached-frontier.js"; import { HttpGossipTransport } from "../gossip/http-transport.js"; import { DefaultGossipService } from "../gossip/protocol.js"; import { createLocalRuntime } from "../local/runtime.js"; +import { NexusBountyStore } from "../nexus/nexus-bounty-store.js"; +import { NexusCas } from "../nexus/nexus-cas.js"; +import { NexusClaimStore } from "../nexus/nexus-claim-store.js"; +import { NexusContributionStore } from "../nexus/nexus-contribution-store.js"; +import { NexusHttpClient } from "../nexus/nexus-http-client.js"; +import { NexusOutcomeStore } from "../nexus/nexus-outcome-store.js"; import { parseGossipSeeds, parsePort } from "../shared/env.js"; import { createApp } from "./app.js"; import type { ServerDeps } from "./deps.js"; @@ -20,12 +30,45 @@ const GROVE_DIR = process.env.GROVE_DIR ?? join(process.cwd(), ".grove"); const PORT = parsePort(process.env.PORT, 4515); const HOST = process.env.HOST; // optional — defaults to localhost via Bun +// Use nexus-backed stores when grove.json declares mode "nexus", else local SQLite +const configPath = join(GROVE_DIR, "grove.json"); +const groveConfig = existsSync(configPath) + ? (() => { + try { + return parseGroveConfig(readFileSync(configPath, "utf-8")); + } catch { + return null; + } + })() + : null; + const runtime = createLocalRuntime({ groveDir: GROVE_DIR, - workspace: false, // server doesn't need workspace manager - parseContract: true, // parse topology from GROVE.md + workspace: false, + parseContract: true, }); +// Build nexus stores if configured, otherwise fall through to runtime's SQLite stores +const useNexus = groveConfig?.mode === "nexus" && groveConfig.nexusUrl; +const nexusStores = + useNexus && groveConfig?.nexusUrl + ? (() => { + const apiKey = process.env.NEXUS_API_KEY || undefined; + const client = new NexusHttpClient({ url: groveConfig.nexusUrl, apiKey }); + const cfg = { client, zoneId: "default" }; + const contributionStore = new NexusContributionStore(cfg); + const baseFrontier = new DefaultFrontierCalculator(contributionStore); + return { + contributionStore, + claimStore: new NexusClaimStore(cfg), + outcomeStore: new NexusOutcomeStore(cfg), + bountyStore: new NexusBountyStore(cfg), + cas: new NexusCas(cfg), + frontier: new CachedFrontierCalculator(baseFrontier, 30_000), + }; + })() + : null; + // --------------------------------------------------------------------------- // Optional gossip federation // --------------------------------------------------------------------------- @@ -54,13 +97,13 @@ if (seedPeers.length > 0) { // --------------------------------------------------------------------------- const deps: ServerDeps = { - contributionStore: runtime.contributionStore, - claimStore: runtime.claimStore, - outcomeStore: runtime.outcomeStore, - bountyStore: runtime.bountyStore, - goalSessionStore: runtime.goalSessionStore, - cas: runtime.cas, - frontier: runtime.frontier, + contributionStore: nexusStores?.contributionStore ?? runtime.contributionStore, + claimStore: nexusStores?.claimStore ?? runtime.claimStore, + outcomeStore: nexusStores?.outcomeStore ?? runtime.outcomeStore, + bountyStore: nexusStores?.bountyStore ?? runtime.bountyStore, + goalSessionStore: runtime.goalSessionStore, // always local (no nexus equivalent yet) + cas: nexusStores?.cas ?? runtime.cas, + frontier: nexusStores?.frontier ?? runtime.frontier, gossip: gossipService, topology: runtime.contract?.topology, }; diff --git a/src/shared/service-lifecycle.ts b/src/shared/service-lifecycle.ts index 505217b2..d376255c 100644 --- a/src/shared/service-lifecycle.ts +++ b/src/shared/service-lifecycle.ts @@ -69,8 +69,16 @@ export async function startServices(options: ServiceStartOptions): Promise { + if (!spawnManagerRef.current) { + showError("SpawnManager not initialized"); + return; + } + spawnManagerRef.current.spawn(agentId, command, parentAgentId, depth).catch((err) => { const msg = err instanceof Error ? err.message : "Spawn failed"; showError(msg); }); diff --git a/src/tui/puppet-server.ts b/src/tui/puppet-server.ts new file mode 100644 index 00000000..d8acf2a3 --- /dev/null +++ b/src/tui/puppet-server.ts @@ -0,0 +1,241 @@ +/** + * TUI Puppet Server — programmatic remote control for the Grove TUI. + * + * When GROVE_PUPPET_PORT is set, runs the TUI as a child process inside a + * pseudo-terminal (via `script`), captures its output, and exposes HTTP + * endpoints for key injection and screen capture. + * + * Endpoints: + * POST /key — inject a key event: { name, ctrl?, shift?, meta? } + * GET /screen — current terminal content as plain text + * GET /screen/raw — last raw ANSI output chunk + * GET /ready — returns 200 when TUI is rendering + * POST /type — type a string: { text } + * + * Usage: + * GROVE_PUPPET_PORT=5100 grove up + * curl http://localhost:5100/screen + * curl -X POST http://localhost:5100/key -d '{"name":"3"}' + */ + +import { spawn } from "node:child_process"; + +/** Special key name → raw escape sequence mapping. */ +const KEY_MAP: Record = { + return: "\r", + enter: "\r", + escape: "\x1b", + tab: "\t", + backspace: "\x7f", + up: "\x1b[A", + down: "\x1b[B", + right: "\x1b[C", + left: "\x1b[D", + home: "\x1b[H", + end: "\x1b[F", + pageup: "\x1b[5~", + pagedown: "\x1b[6~", + delete: "\x1b[3~", + space: " ", +}; + +/** Convert a key request to raw bytes for the pty. */ +function keyToRaw(name: string, ctrl?: boolean): string { + if (ctrl && name.length === 1) { + // Ctrl+letter = char code 1-26 + const code = name.toLowerCase().charCodeAt(0) - 96; + if (code >= 1 && code <= 26) return String.fromCharCode(code); + } + return KEY_MAP[name.toLowerCase()] ?? name; +} + +/** + * Start the puppet server. Spawns the TUI inside a pty via `script`, + * captures output, and serves an HTTP control API. + * + * @param tuiArgs - Command-line arguments to pass to the TUI process + * @param port - HTTP port for the puppet API + * @returns Promise that resolves when the TUI exits + */ +export async function startPuppetAndServe(tuiArgs: string[], port: number): Promise { + // Raw ANSI output buffer — ring buffer of recent chunks + const outputChunks: string[] = []; + const MAX_CHUNKS = 200; + let ready = false; + + // Virtual screen buffer: parse ANSI into a 2D text grid + const ROWS = Number(process.env.LINES) || 24; + const COLS = Number(process.env.COLUMNS) || 80; + const screen: string[][] = Array.from({ length: ROWS }, () => Array(COLS).fill(" ")); + let curRow = 0; + let curCol = 0; + + function processAnsi(data: string): void { + // Simple ANSI parser: handles cursor positioning and plain text + let i = 0; + while (i < data.length) { + if (data[i] === "\x1b" && data[i + 1] === "[") { + // CSI sequence + let j = i + 2; + let params = ""; + while (j < data.length) { + const ch = data[j] as string; + if ((ch >= "0" && ch <= "9") || ch === ";" || ch === "?") { + params += ch; + j++; + } else break; + } + const cmd = j < data.length ? data[j] : ""; + j++; + if (cmd === "H" || cmd === "f") { + // Cursor position: ESC[row;colH + const parts = params.split(";"); + curRow = Math.max(0, Math.min(ROWS - 1, parseInt(parts[0] || "1", 10) - 1)); + curCol = Math.max(0, Math.min(COLS - 1, parseInt(parts[1] || "1", 10) - 1)); + } else if (cmd === "J") { + // Clear screen + if (params === "2" || params === "") { + for (let r = 0; r < ROWS; r++) screen[r]?.fill(" "); + } + } else if (cmd === "K") { + // Clear line + const clearRow = screen[curRow]; + if (curRow < ROWS && clearRow) { + for (let c = curCol; c < COLS; c++) clearRow[c] = " "; + } + } else if (cmd === "A") { + curRow = Math.max(0, curRow - parseInt(params || "1", 10)); + } else if (cmd === "B") { + curRow = Math.min(ROWS - 1, curRow + parseInt(params || "1", 10)); + } else if (cmd === "C") { + curCol = Math.min(COLS - 1, curCol + parseInt(params || "1", 10)); + } else if (cmd === "D") { + curCol = Math.max(0, curCol - parseInt(params || "1", 10)); + } + // Skip color/style codes (m) and other CSI commands + i = j; + } else if (data[i] === "\x1b") { + // Other escape sequences — skip to next non-escape + i += 2; + } else if (data[i] === "\r") { + curCol = 0; + i++; + } else if (data[i] === "\n") { + curRow = Math.min(ROWS - 1, curRow + 1); + curCol = 0; + i++; + } else { + // Regular character + if (curRow < ROWS && curCol < COLS) { + const row = screen[curRow]; + const ch = data[i]; + if (row && ch !== undefined) row[curCol] = ch; + curCol++; + if (curCol >= COLS) { + curCol = 0; + curRow = Math.min(ROWS - 1, curRow + 1); + } + } + i++; + } + } + } + + function getScreen(): string { + return screen.map((row) => row.join("").trimEnd()).join("\n"); + } + + // Spawn TUI inside a pty. Use Python's pty module which works without + // a real terminal on stdin (unlike `script` on macOS). + const pyPtyScript = ` +import pty, os, sys, json +cmd = json.loads(sys.argv[1]) +os.environ.pop('GROVE_PUPPET_PORT', None) +os.environ['TERM'] = os.environ.get('TERM', 'xterm-256color') +os.environ['COLUMNS'] = '${COLS}' +os.environ['LINES'] = '${ROWS}' +pty.spawn(cmd) +`; + const tuiProc = spawn("python3", ["-c", pyPtyScript, JSON.stringify(["bun", ...tuiArgs])], { + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + TERM: process.env.TERM || "xterm-256color", + COLUMNS: String(COLS), + LINES: String(ROWS), + }, + }); + + tuiProc.stdout?.on("data", (chunk: Buffer) => { + const str = chunk.toString(); + outputChunks.push(str); + if (outputChunks.length > MAX_CHUNKS) { + outputChunks.splice(0, outputChunks.length - MAX_CHUNKS); + } + processAnsi(str); + if (!ready && str.length > 100) ready = true; + }); + + tuiProc.stderr?.on("data", (chunk: Buffer) => { + process.stderr.write(chunk); + }); + + // HTTP puppet server + const server = Bun.serve({ + port, + hostname: "127.0.0.1", + fetch(request: Request): Response | Promise { + const url = new URL(request.url); + + if (request.method === "GET" && url.pathname === "/ready") { + return Response.json({ ready, pid: tuiProc.pid, rows: ROWS, cols: COLS }); + } + + if (request.method === "GET" && url.pathname === "/screen") { + return new Response(getScreen(), { headers: { "content-type": "text/plain" } }); + } + + if (request.method === "GET" && url.pathname === "/screen/raw") { + return new Response(outputChunks.slice(-20).join(""), { + headers: { "content-type": "text/plain" }, + }); + } + + if (request.method === "POST" && url.pathname === "/key") { + return (async () => { + const body = (await request.json()) as { name: string; ctrl?: boolean }; + if (!body.name) return Response.json({ error: "missing 'name'" }, { status: 400 }); + const raw = keyToRaw(body.name, body.ctrl); + tuiProc.stdin?.write(raw); + await new Promise((r) => setTimeout(r, 100)); + return Response.json({ ok: true, key: body.name }); + })(); + } + + if (request.method === "POST" && url.pathname === "/type") { + return (async () => { + const body = (await request.json()) as { text: string }; + if (!body.text) return Response.json({ error: "missing 'text'" }, { status: 400 }); + for (const ch of body.text) { + tuiProc.stdin?.write(ch); + await new Promise((r) => setTimeout(r, 30)); + } + return Response.json({ ok: true, typed: body.text }); + })(); + } + + return Response.json({ error: "not found" }, { status: 404 }); + }, + }); + + console.error(`[puppet] server on http://127.0.0.1:${server.port} (TUI pid=${tuiProc.pid})`); + + // Wait for TUI to exit + return new Promise((resolve) => { + tuiProc.on("exit", (code) => { + console.error(`[puppet] TUI exited with code ${code}`); + server.stop(); + resolve(); + }); + }); +} diff --git a/src/tui/resolve-backend.ts b/src/tui/resolve-backend.ts index 4181e4f5..b472fc3a 100644 --- a/src/tui/resolve-backend.ts +++ b/src/tui/resolve-backend.ts @@ -165,9 +165,12 @@ export async function checkNexusHealth( timeoutMs?: number, ): Promise { try { + const apiKey = process.env.NEXUS_API_KEY; + const headers: Record = { "Content-Type": "application/json" }; + if (apiKey) headers.Authorization = `Bearer ${apiKey}`; const resp = await fetch(`${url.replace(/\/+$/, "")}/api/nfs/exists`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers, body: JSON.stringify({ jsonrpc: "2.0", method: "exists", params: { path: "/" }, id: 1 }), signal: AbortSignal.timeout(timeoutMs ?? DEFAULT_HEALTH_TIMEOUT_MS), }); diff --git a/src/tui/spawn-manager.ts b/src/tui/spawn-manager.ts index 42715ce6..16ded441 100644 --- a/src/tui/spawn-manager.ts +++ b/src/tui/spawn-manager.ts @@ -142,9 +142,18 @@ export class SpawnManager { } : undefined; + // Resolve relative commands against the grove/project root so that + // topology commands like "scripts/grove-agent.sh coder" work even + // when the tmux session cwd is the workspace checkout directory. + const groveRoot = process.cwd(); + const { resolve: resolvePath, isAbsolute } = await import("node:path"); + const resolvedCommand = command.replace(/^(\S+)/, (match) => + isAbsolute(match) ? match : resolvePath(groveRoot, match), + ); + const options: SpawnOptions = { agentId: spawnId, - command, + command: resolvedCommand, targetRef: spawnId, workspacePath, ...(prEnv ? { env: prEnv } : {}),