From 1237770b5e42a7bf58993427953fa7c85552d115 Mon Sep 17 00:00:00 2001 From: Yuto Nishida Date: Fri, 17 Jul 2026 22:35:50 -0700 Subject: [PATCH] [papyrus] Remove Claude Code hooks + live status; record fresh sessions via --session-id The plugin/status-hook path (claude-code-plugin/ -> POST /status-update) was buggy and Claude-specific, and it was the only mechanism recording a fresh session's real id into silverwood. Remove all hook usage and the live-status UI. Fresh + sessions now spawn 'claude --session-id ' and record the durable silverwood session + advisory lock at spawn (recordFreshSession), so the durable record no longer depends on a runtime callback. Runtime key equals the claude session id equals the silverwood key everywhere: drop the provisional-uuid reconciliation, Session.claudeSessionId, and the resolveRuntime scan. Also removed: --plugin-dir injection, the OPENUI_* env, the plugin auto-install, the WebSocket status frames, and all plugin transient/dead Session fields. Node and sidebar now show connection + checkout state only (no agent activity). Tab title is silverwood's session name, verbatim. The claude-code-plugin/ tree is left dormant in-repo (usage removed, not deleted). Verified: nix build .#default (client tsc strict + package) green; end-to-end against a live forest (projection, fresh-session record+lock with no hook, disconnect release). Co-Authored-By: Claude Opus 4.8 (1M context) --- experimental/papyrus/bin/papyrus.ts | 38 +--- experimental/papyrus/client/src/App.tsx | 3 +- .../components/AgentNode/AgentNodeCard.tsx | 147 +++---------- .../client/src/components/AgentNode/index.tsx | 13 +- .../papyrus/client/src/components/Sidebar.tsx | 37 ++-- .../client/src/components/Terminal.tsx | 13 +- .../papyrus/client/src/stores/useStore.ts | 14 +- experimental/papyrus/server/config.ts | 8 +- experimental/papyrus/server/index.ts | 10 +- experimental/papyrus/server/routes/api.ts | 193 +++++------------- .../papyrus/server/services/sessionManager.ts | 109 ++-------- experimental/papyrus/server/types/index.ts | 36 +--- 12 files changed, 136 insertions(+), 485 deletions(-) diff --git a/experimental/papyrus/bin/papyrus.ts b/experimental/papyrus/bin/papyrus.ts index 7141c84..a4c738e 100755 --- a/experimental/papyrus/bin/papyrus.ts +++ b/experimental/papyrus/bin/papyrus.ts @@ -1,9 +1,7 @@ #!/usr/bin/env bun import { $ } from "bun"; -import { existsSync } from "fs"; import { join } from "path"; -import { homedir } from "os"; // Read version from package.json const packageJson = await Bun.file(join(import.meta.dir, "..", "package.json")).json(); @@ -13,39 +11,6 @@ const PORT = process.env.PORT || 6969; const LAUNCH_CWD = process.cwd(); const IS_DEV = process.env.NODE_ENV === "development" || process.argv.includes("--dev"); -// Auto-install plugin if not present -async function ensurePluginInstalled() { - const pluginDir = join(homedir(), ".openui", "claude-code-plugin"); - const pluginJson = join(pluginDir, ".claude-plugin", "plugin.json"); - - if (existsSync(pluginJson)) { - return; // Plugin already installed - } - - console.log("\x1b[38;5;141m[plugin]\x1b[0m Installing Claude Code plugin..."); - - const GITHUB_RAW = "https://raw.githubusercontent.com/Fallomai/openui/main/claude-code-plugin"; - - try { - // Create directories - await $`mkdir -p ${pluginDir}/.claude-plugin ${pluginDir}/hooks`.quiet(); - - // Download plugin files - await Promise.all([ - $`curl -sL ${GITHUB_RAW}/.claude-plugin/plugin.json -o ${pluginDir}/.claude-plugin/plugin.json`.quiet(), - $`curl -sL ${GITHUB_RAW}/hooks/hooks.json -o ${pluginDir}/hooks/hooks.json`.quiet(), - $`curl -sL ${GITHUB_RAW}/hooks/status-reporter.sh -o ${pluginDir}/hooks/status-reporter.sh`.quiet(), - ]); - - // Make script executable - await $`chmod +x ${pluginDir}/hooks/status-reporter.sh`.quiet(); - - console.log("\x1b[38;5;82m[plugin]\x1b[0m Plugin installed successfully!"); - } catch (e) { - console.error("\x1b[38;5;196m[plugin]\x1b[0m Failed to install plugin:", e); - } -} - // Check for updates (non-blocking) async function checkForUpdates() { try { @@ -108,8 +73,7 @@ console.log(` \x1b[38;5;245m Press Ctrl+C to stop\x1b[0m `); -// Ensure plugin is installed and check for updates in background -await ensurePluginInstalled(); +// Check for updates in the background. checkForUpdates(); // Start the server with LAUNCH_CWD env var diff --git a/experimental/papyrus/client/src/App.tsx b/experimental/papyrus/client/src/App.tsx index 82558ea..8326870 100644 --- a/experimental/papyrus/client/src/App.tsx +++ b/experimental/papyrus/client/src/App.tsx @@ -106,7 +106,8 @@ function AppContent() { color, createdAt: n.createdAt, cwd: n.cwd, - status: (n.status || "idle") as AgentSession["status"], + connected: !!n.connected, + checkoutState: n.checkoutState, customName: n.customName, customColor: n.customColor, notes: n.notes, diff --git a/experimental/papyrus/client/src/components/AgentNode/AgentNodeCard.tsx b/experimental/papyrus/client/src/components/AgentNode/AgentNodeCard.tsx index d3bc290..8b28e9f 100644 --- a/experimental/papyrus/client/src/components/AgentNode/AgentNodeCard.tsx +++ b/experimental/papyrus/client/src/components/AgentNode/AgentNodeCard.tsx @@ -1,30 +1,17 @@ -import { MessageSquare, WifiOff, GitBranch, Folder, Wrench } from "lucide-react"; -import { AgentStatus } from "../../stores/useStore"; +import { GitBranch, Folder } from "lucide-react"; -// Status config with visual priority levels -const statusConfig: Record = { - running: { label: "Working", color: "#22C55E", bgColor: "#22C55E15", isActive: true }, - tool_calling: { label: "Working", color: "#22C55E", bgColor: "#22C55E15", isActive: true }, - waiting_input: { label: "Needs Input", color: "#F97316", bgColor: "#F9731620", needsAttention: true }, - idle: { label: "Idle", color: "#FBBF24", bgColor: "#FBBF2415", needsAttention: true }, - disconnected: { label: "Offline", color: "#6B7280", bgColor: "#6B728015" }, - error: { label: "Error", color: "#EF4444", bgColor: "#EF444415", needsAttention: true }, -}; - -// Tool name display mapping -const toolDisplayNames: Record = { - Read: "Reading", - Write: "Writing", - Edit: "Editing", - Bash: "Running", - Grep: "Searching", - Glob: "Finding", - Task: "Tasking", - WebFetch: "Fetching", - WebSearch: "Searching", - TodoWrite: "Planning", - AskUserQuestion: "Asking", -}; +// A single node indicator folding connection + checkout state (no live agent +// activity — that needed the plugin hook). Precedence: checkout problems first, +// then whether a session is connected in this papyrus instance. +function nodeIndicator( + connected: boolean, + checkoutState?: string, +): { label: string; color: string } { + if (checkoutState === "failed") return { label: "Checkout failed", color: "#EF4444" }; + if (checkoutState === "pending") return { label: "Cloning…", color: "#FBBF24" }; + if (connected) return { label: "Connected", color: "#22C55E" }; + return { label: "Disconnected", color: "#6B7280" }; +} interface AgentNodeCardProps { selected: boolean; @@ -32,8 +19,8 @@ interface AgentNodeCardProps { displayName: string; Icon: any; agentId: string; - status: AgentStatus; - currentTool?: string; + connected: boolean; + checkoutState?: string; cwd?: string; originalCwd?: string; // Mother repo path when using worktrees gitBranch?: string; @@ -47,8 +34,8 @@ export function AgentNodeCard({ displayName, Icon, agentId, - status, - currentTool, + connected, + checkoutState, cwd, originalCwd, gitBranch, @@ -57,18 +44,12 @@ export function AgentNodeCard({ }: AgentNodeCardProps) { // agentId is available for future use if needed void agentId; - const statusInfo = statusConfig[status] || statusConfig.idle; - const isActive = statusInfo.isActive; - const isToolCalling = status === "tool_calling"; - const needsAttention = statusInfo.needsAttention; + const indicator = nodeIndicator(connected, checkoutState); // Extract directory name - use originalCwd (mother repo) if available, otherwise cwd const displayCwd = originalCwd || cwd; const dirName = displayCwd ? displayCwd.split("/").pop() || displayCwd : null; - // Get display name for current tool - const toolDisplay = currentTool ? (toolDisplayNames[currentTool] || currentTool) : null; - return (
- {/* Animated effects for different states */} - {isActive && !needsAttention && ( -
- )} - {/* Pulsing border for attention states */} - {needsAttention && ( -
- )} - {/* Color bar at top */}
- {/* Status banner */} -
-
- {/* Status indicator - animated ring for active */} -
-
- {isActive && ( -
- )} -
- - {statusInfo.label} - - {/* Show current tool when tool_calling */} - {isToolCalling && toolDisplay && ( - - - {toolDisplay} - - )} -
- {status === "waiting_input" && ( - - )} - {status === "disconnected" && ( - - )} + {/* Indicator banner: connection + checkout */} +
+
+ + {indicator.label} +
@@ -203,18 +120,6 @@ export function AgentNodeCard({ )}
- - {/* CSS for animations */} -
); } diff --git a/experimental/papyrus/client/src/components/AgentNode/index.tsx b/experimental/papyrus/client/src/components/AgentNode/index.tsx index cd2ef14..ff2c0a1 100644 --- a/experimental/papyrus/client/src/components/AgentNode/index.tsx +++ b/experimental/papyrus/client/src/components/AgentNode/index.tsx @@ -1,7 +1,7 @@ import { NodeProps } from "@xyflow/react"; import { motion } from "framer-motion"; import { Sparkles, Code, Cpu, Zap, Rocket, Bot, Brain, Wand2 } from "lucide-react"; -import { useStore, AgentStatus } from "../../stores/useStore"; +import { useStore } from "../../stores/useStore"; import { AgentNodeCard } from "./AgentNodeCard"; import { AgentNodeContextMenu } from "./AgentNodeContextMenu"; import { useAgentNodeState } from "./useAgentNodeState"; @@ -28,9 +28,10 @@ interface AgentNodeData { export const AgentNode = ({ id, data, selected }: NodeProps) => { const nodeData = data as unknown as AgentNodeData; - // Subscribe directly to status and currentTool as primitive values - this guarantees re-render on change - const status: AgentStatus = useStore((state) => state.sessions.get(id)?.status) || "idle"; - const currentTool = useStore((state) => state.sessions.get(id)?.currentTool); + // Subscribe to the node's connection + checkout state as primitives so the card + // re-renders when they change. + const connected = useStore((state) => state.sessions.get(id)?.connected) || false; + const checkoutState = useStore((state) => state.sessions.get(id)?.checkoutState); // Get the full session for other data const session = useStore((state) => state.sessions.get(id)); @@ -60,8 +61,8 @@ export const AgentNode = ({ id, data, selected }: NodeProps) => { displayName={displayName} Icon={Icon} agentId={nodeData.agentId} - status={status} - currentTool={currentTool} + connected={connected} + checkoutState={checkoutState} cwd={session?.cwd} originalCwd={session?.originalCwd} gitBranch={session?.gitBranch} diff --git a/experimental/papyrus/client/src/components/Sidebar.tsx b/experimental/papyrus/client/src/components/Sidebar.tsx index f44e60c..2e72be5 100644 --- a/experimental/papyrus/client/src/components/Sidebar.tsx +++ b/experimental/papyrus/client/src/components/Sidebar.tsx @@ -21,18 +21,9 @@ import { Wand2, GitBranch, } from "lucide-react"; -import { useStore, AgentStatus, SessionTab } from "../stores/useStore"; +import { useStore, SessionTab } from "../stores/useStore"; import { Terminal } from "./Terminal"; -const statusConfig: Record = { - running: { label: "Running", color: "#22C55E" }, - waiting_input: { label: "Waiting for input", color: "#FBBF24" }, - tool_calling: { label: "Tool Calling", color: "#8B5CF6" }, - idle: { label: "Idle", color: "#6B7280" }, - disconnected: { label: "Disconnected", color: "#EF4444" }, - error: { label: "Error", color: "#EF4444" }, -}; - const presetColors = [ "#F97316", "#22C55E", "#3B82F6", "#8B5CF6", "#EC4899", "#EF4444", "#FBBF24", "#14B8A6" ]; @@ -48,11 +39,9 @@ const iconOptions = [ { id: "wand2", icon: Wand2, label: "Wand" }, ]; -// A short, human-legible label for a session tab. +// The tab title is silverwood's session `name`, verbatim. function tabLabel(t: SessionTab): string { - if (t.name && t.name !== t.claudeSessionId && t.name !== "claude") return t.name; - const id = t.claudeSessionId || t.sessionId; - return id.length > 8 ? id.slice(0, 8) : id; + return t.name; } export function Sidebar() { @@ -98,7 +87,6 @@ export function Sidebar() { createdAt: new Date().toISOString(), kind: "claude-code", connected: true, - status: "running", lock: null, ...ov, }); @@ -164,7 +152,7 @@ export function Sidebar() { const res = await fetch(`/api/sessions/${selectedNodeId}/sessions/connect`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sessionId: tab.claudeSessionId ?? tab.sessionId, force }), + body: JSON.stringify({ sessionId: tab.sessionId, force }), }); const data = await res.json().catch(() => ({})); if (res.status === 409) { @@ -221,7 +209,15 @@ export function Sidebar() { }; const displayColor = editColor || session?.customColor || session?.color || "#888"; - const statusInfo = statusConfig[session?.status || "idle"]; + // Workstream-level indicator: checkout problems first, then connection. + const headerStatus = + session?.checkoutState === "failed" + ? { label: "Checkout failed", color: "#EF4444" } + : session?.checkoutState === "pending" + ? { label: "Cloning…", color: "#FBBF24" } + : session?.connected + ? { label: "Connected", color: "#22C55E" } + : { label: "Disconnected", color: "#6B7280" }; return ( @@ -247,9 +243,9 @@ export function Sidebar() {
- {statusInfo.label} + {headerStatus.label}
@@ -408,7 +404,7 @@ export function Sidebar() {
diff --git a/experimental/papyrus/client/src/components/Terminal.tsx b/experimental/papyrus/client/src/components/Terminal.tsx index 99db04a..8580bdd 100644 --- a/experimental/papyrus/client/src/components/Terminal.tsx +++ b/experimental/papyrus/client/src/components/Terminal.tsx @@ -3,16 +3,13 @@ import { Terminal as XTerm } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { WebLinksAddon } from "@xterm/addon-web-links"; import "@xterm/xterm/css/xterm.css"; -import { useStore, AgentStatus } from "../stores/useStore"; interface TerminalProps { sessionId: string; color: string; - nodeId: string; } -export function Terminal({ sessionId, color, nodeId }: TerminalProps) { - const updateSession = useStore((state) => state.updateSession); +export function Terminal({ sessionId, color }: TerminalProps) { const terminalRef = useRef(null); const xtermRef = useRef(null); const wsRef = useRef(null); @@ -113,12 +110,6 @@ export function Terminal({ sessionId, color, nodeId }: TerminalProps) { term.write("\x1b[2J\x1b[H\x1b[0m"); } term.write(msg.data); - } else if (msg.type === "status") { - // Handle status updates from plugin hooks - updateSession(nodeId, { - status: msg.status as AgentStatus, - currentTool: msg.currentTool, - }); } } catch (e) { term.write(event.data); @@ -167,7 +158,7 @@ export function Terminal({ sessionId, color, nodeId }: TerminalProps) { ws?.close(); term.dispose(); }; - }, [sessionId, color, nodeId, updateSession]); + }, [sessionId, color]); return (
({ const r = resolveRuntime(sessionId); if (!r) return new Response("Session not found", { status: 404 }); - // Key the socket by the resolved registry key (a fresh session is addressed - // by a provisional id but registered under it). + // The registry key is the claude session id; key the socket by it. const upgraded = server.upgrade(req, { data: { sessionId: r[0] } }); if (upgraded) return undefined; return new Response("WebSocket upgrade failed", { status: 400 }); @@ -62,8 +61,6 @@ Bun.serve({ if (session.outputBuffer.length > 0) { ws.send(JSON.stringify({ type: "output", data: session.outputBuffer.join("") })); } - - ws.send(JSON.stringify({ type: "status", status: session.status })); }, message(ws, message) { const { sessionId } = ws.data; @@ -76,7 +73,6 @@ Bun.serve({ case "input": if (session.pty) { session.pty.write(msg.data); - session.lastInputTime = Date.now(); } break; case "resize": @@ -110,9 +106,9 @@ log(`\x1b[38;5;245m[server]\x1b[0m Forest: ${process.env.SILVERWOOD_FOREST_PATH // Cleanup on exit: release advisory locks this instance holds (best-effort), then // kill terminals. A hard crash skips this; a stuck lock is recovered via a force-steal. process.on("SIGINT", async () => { - const held = [...sessions.values()].filter((s) => s.holdsLock && s.claudeSessionId); + const held = [...sessions.entries()].filter(([, s]) => s.holdsLock); await Promise.allSettled( - held.map((s) => sw.sessionUnlock(s.workstreamId, s.claudeSessionId!, HOLDER)), + held.map(([key, s]) => sw.sessionUnlock(s.workstreamId, key, HOLDER)), ); for (const [, session] of sessions) session.pty.kill(); process.exit(0); diff --git a/experimental/papyrus/server/routes/api.ts b/experimental/papyrus/server/routes/api.ts index 0eac86e..4355be4 100644 --- a/experimental/papyrus/server/routes/api.ts +++ b/experimental/papyrus/server/routes/api.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import type { Agent } from "../types"; +import type { Agent, Session } from "../types"; import { randomUUID } from "crypto"; import { sessions, @@ -69,46 +69,32 @@ async function buildNode(ws: sw.Workstream) { logError(`\x1b[38;5;141m[sessions]\x1b[0m ${ws.id}: ${e.message}`); } const tabs: any[] = []; - const claimed = new Set(); - for (const [claudeId, rec] of Object.entries(durable)) { - const match = runtimes.find(([k, s]) => k === claudeId || s.claudeSessionId === claudeId); - if (match) claimed.add(match[0]); + for (const [sid, rec] of Object.entries(durable)) { + // The registry key IS the session id, so a live PTY is a direct lookup. + const live = runtimes.some(([k]) => k === sid); tabs.push({ - sessionId: match ? match[0] : claudeId, - claudeSessionId: claudeId, + sessionId: sid, name: rec.name, createdAt: rec.created_at, kind: rec.kind, - connected: !!match, - status: match ? match[1].status : "disconnected", + connected: live, lock: rec.lock ? { holder: rec.lock.holder, mine: rec.lock.holder === HOLDER } : null, }); } - // Fresh, live PTYs not yet recorded in silverwood (pre status-hook). Surfaced by - // the server (its own runtime truth), so the client never invents a tab. - for (const [key, s] of runtimes) { - if (claimed.has(key)) continue; + // A just-spawned fresh PTY whose `session create` hasn't landed yet (the tiny + // window between spawn and record). Surface it so the tab doesn't flicker. + for (const [key] of runtimes) { + if (durable[key]) continue; tabs.push({ sessionId: key, - claudeSessionId: s.claudeSessionId, name: "claude", createdAt: ws.created_at, kind: "claude-code", connected: true, - status: s.status, lock: null, }); } - const live = tabs.find((t) => t.connected); - const status = live - ? live.status - : cstate === "failed" - ? "error" - : cstate === "pending" - ? "idle" - : "disconnected"; - const position = sw.decodeKv<{ x: number; y: number }>(kv, "position"); return { nodeId: ws.id, @@ -122,7 +108,9 @@ async function buildNode(ws: sw.Workstream) { customColor: sw.decodeKv(kv, "color"), notes: sw.decodeKv(kv, "notes"), ...(position ? { position } : {}), - status, + // Node visuals: is any session connected in THIS papyrus instance, and the + // checkout state. No live agent-activity status (that needed the hook). + connected: tabs.some((t) => t.connected), checkoutState: cstate, source: ws.mode?.initial_source, sessions: tabs, @@ -157,11 +145,24 @@ apiRoutes.get("/sessions", async (c) => { } }); -apiRoutes.get("/sessions/:sessionId/status", (c) => { - const r = resolveRuntime(c.req.param("sessionId")); - if (!r) return c.json({ status: "disconnected" }); - return c.json({ status: r[1].status }); -}); +// Record a freshly-spawned session durably in silverwood (name "claude") and +// acquire its advisory lock for this instance. papyrus mints the id and passes it +// to `claude --session-id`, so this replaces what the plugin hook used to do — +// no runtime callback. Best-effort: a failure is logged, not fatal (the live PTY +// still runs; buildNode surfaces it as a transient tab until the record lands). +async function recordFreshSession( + wsId: string, + sessionId: string, + session: Session, +): Promise { + try { + await sw.sessionCreate(wsId, sessionId, "claude"); + await sw.sessionLock(wsId, sessionId, HOLDER); + session.holdsLock = true; + } catch (e: any) { + logError(`\x1b[38;5;141m[session-register]\x1b[0m ${sessionId}: ${e.message}`); + } +} // Create a node = create a silverwood workstream (clones its checkout), then spawn // its Claude Code terminal. Blocks on the clone; the node comes up ready. @@ -186,7 +187,13 @@ apiRoutes.post("/sessions", async (c) => { let initialSessionId: string | undefined; if (sw.checkoutState(ws) === "ready" && cwd) { initialSessionId = randomUUID(); - spawnTerminal({ sessionKey: initialSessionId, workstreamId: ws.id, cwd, command: "claude" }); + const session = spawnTerminal({ + sessionKey: initialSessionId, + workstreamId: ws.id, + cwd, + command: `claude --session-id ${initialSessionId}`, + }); + await recordFreshSession(ws.id, initialSessionId, session); } return c.json({ @@ -240,16 +247,14 @@ apiRoutes.post("/sessions/:wsId/sessions/connect", async (c) => { workstreamId: wsId, cwd, command: `claude --resume ${sessionId}`, - claudeSessionId: sessionId, - resumed: true, }); session.holdsLock = holdsLock; log(`\x1b[38;5;141m[session]\x1b[0m connected ${sessionId}`); return c.json({ sessionId, connected: true }); }); -// Add a fresh session tab: spawn a new `claude` PTY under a provisional id. Its -// durable silverwood session + lock are recorded later by the status hook. +// Add a fresh session tab: papyrus mints the id, spawns `claude --session-id `, +// and records the durable silverwood session + lock immediately (no hook). apiRoutes.post("/sessions/:wsId/sessions", async (c) => { const wsId = c.req.param("wsId"); let ws: sw.Workstream; @@ -263,7 +268,13 @@ apiRoutes.post("/sessions/:wsId/sessions", async (c) => { return c.json({ error: "checkout not ready" }, 400); } const sessionId = randomUUID(); - spawnTerminal({ sessionKey: sessionId, workstreamId: wsId, cwd, command: "claude" }); + const session = spawnTerminal({ + sessionKey: sessionId, + workstreamId: wsId, + cwd, + command: `claude --session-id ${sessionId}`, + }); + await recordFreshSession(wsId, sessionId, session); log(`\x1b[38;5;141m[session]\x1b[0m spawned fresh ${sessionId}`); return c.json({ sessionId, connected: true }); }); @@ -315,116 +326,6 @@ apiRoutes.delete("/sessions/:sessionId", async (c) => { return c.json({ success: true }); }); -// Status update endpoint for the Claude Code plugin. Purely in-memory, except -// that the first time we learn a Claude session id we record a silverwood session. -apiRoutes.post("/status-update", async (c) => { - const body = await c.req.json(); - const { status, openuiSessionId, claudeSessionId, hookEvent, toolName } = body; - - log( - `\x1b[38;5;82m[plugin-hook]\x1b[0m ${hookEvent || "unknown"}: status=${status} tool=${toolName || "none"} openui=${openuiSessionId || "none"}`, - ); - - if (!status) return c.json({ error: "status is required" }, 400); - - let foundId: string | undefined; - let session = openuiSessionId ? sessions.get(openuiSessionId) : undefined; - if (session) foundId = openuiSessionId; - if (!session && claudeSessionId) { - for (const [id, s] of sessions) { - if (s.claudeSessionId === claudeSessionId) { - session = s; - foundId = id; - break; - } - } - } - - if (!session || !foundId) { - return c.json({ success: true, warning: "No matching session found" }); - } - - if (claudeSessionId && !session.claudeSessionId) { - session.claudeSessionId = claudeSessionId; - } - // Record the agent session in silverwood once, under its WORKSTREAM id (the - // registry key is a provisional uuid), then acquire its advisory lock so other - // clients see it as in-use. Fire-and-forget. - if (claudeSessionId && !session.silverwoodSessionRecorded) { - session.silverwoodSessionRecorded = true; - const s = session; - sw.sessionCreate(s.workstreamId, claudeSessionId, "claude") - .then(() => sw.sessionLock(s.workstreamId, claudeSessionId, HOLDER)) - .then(() => { - s.holdsLock = true; - }) - .catch((e) => log(`\x1b[38;5;141m[session-register]\x1b[0m ${e.message}`)); - } - - // Permission detection: a PreToolUse with no matching PostToolUse within 2.5s - // means the agent is waiting for the user to grant permission. - let effectiveStatus = status; - if (status === "pre_tool") { - effectiveStatus = "running"; - session.currentTool = toolName; - session.preToolTime = Date.now(); - if (session.permissionTimeout) clearTimeout(session.permissionTimeout); - session.permissionTimeout = setTimeout(() => { - if (session!.preToolTime) { - session!.status = "waiting_input"; - for (const client of session!.clients) { - if (client.readyState === 1) { - client.send( - JSON.stringify({ - type: "status", - status: "waiting_input", - currentTool: session!.currentTool, - hookEvent: "permission_timeout", - }), - ); - } - } - } - }, 2500); - } else if (status === "post_tool") { - effectiveStatus = "running"; - session.preToolTime = undefined; - if (session.permissionTimeout) { - clearTimeout(session.permissionTimeout); - session.permissionTimeout = undefined; - } - } else { - if (status !== "tool_calling" && status !== "running") { - session.currentTool = undefined; - } - session.preToolTime = undefined; - if (session.permissionTimeout) { - clearTimeout(session.permissionTimeout); - session.permissionTimeout = undefined; - } - } - - session.status = effectiveStatus; - session.pluginReportedStatus = true; - session.lastPluginStatusTime = Date.now(); - session.lastHookEvent = hookEvent; - - for (const client of session.clients) { - if (client.readyState === 1) { - client.send( - JSON.stringify({ - type: "status", - status: session.status, - currentTool: session.currentTool, - hookEvent, - }), - ); - } - } - - return c.json({ success: true }); -}); - // ============ Linear (read-only; API key from $LINEAR_API_KEY) ============ apiRoutes.get("/linear/config", (c) => { diff --git a/experimental/papyrus/server/services/sessionManager.ts b/experimental/papyrus/server/services/sessionManager.ts index 362e1b2..14b215d 100644 --- a/experimental/papyrus/server/services/sessionManager.ts +++ b/experimental/papyrus/server/services/sessionManager.ts @@ -1,9 +1,6 @@ import { spawn as spawnPty } from "bun-pty"; -import { existsSync } from "fs"; -import { join } from "path"; -import { homedir, hostname } from "os"; +import { hostname } from "os"; import { randomUUID } from "crypto"; -import { PORT, HOST } from "../config"; import type { Session } from "../types"; import * as sw from "./silverwood"; @@ -16,80 +13,42 @@ const log = QUIET ? () => {} : console.log.bind(console); // deliberately instead. export const HOLDER = `papyrus:${hostname()}:${process.pid}:${randomUUID().slice(0, 8)}`; -// Locate the Claude Code plugin dir (adds the status hooks that drive node -// state). Installed under ~/.openui/claude-code-plugin, or vendored in-repo. -function getPluginDir(): string | null { - const homePluginDir = join(homedir(), ".openui", "claude-code-plugin"); - if (existsSync(join(homePluginDir, ".claude-plugin", "plugin.json"))) { - return homePluginDir; - } - const repoPluginDir = join(import.meta.dir || __dirname, "..", "..", "claude-code-plugin"); - if (existsSync(join(repoPluginDir, ".claude-plugin", "plugin.json"))) { - return repoPluginDir; - } - return null; -} - -// Inject `--plugin-dir` into a `claude` command so the plugin's hooks fire. -export function injectPluginDir(command: string, agentId: string): string { - if (agentId !== "claude") return command; - const pluginDir = getPluginDir(); - if (!pluginDir) return command; - if (command.includes("--plugin-dir")) return command; - const parts = command.split(/\s+/); - if (parts[0] === "claude") { - parts.splice(1, 0, "--plugin-dir", pluginDir); - return parts.join(" "); - } - return command; -} - const MAX_BUFFER_SIZE = 1000; -// The in-memory live-PTY registry, keyed by runtime session key. An entry exists -// iff a PTY is live. Purely ephemeral — PTYs, WebSocket clients, scrollback, and -// live status all live here and vanish on restart. All durable state is in -// silverwood; nothing here is cached from it. +// The in-memory live-PTY registry, keyed by the claude session id. An entry exists +// iff a PTY is live. Purely ephemeral — PTYs, WebSocket clients, and scrollback +// live here and vanish on restart. All durable state is in silverwood; nothing +// here is cached from it. export const sessions = new Map(); // Release the advisory lock this instance holds for a session, if any // (best-effort; a crash skips it and the lock is recovered via a force-steal). -function releaseLock(session: Session): void { - if (session.holdsLock && session.claudeSessionId) { +// The registry key IS the silverwood session id. +function releaseLock(sessionKey: string, session: Session): void { + if (session.holdsLock) { session.holdsLock = false; - sw.sessionUnlock(session.workstreamId, session.claudeSessionId, HOLDER).catch((e) => - log(`\x1b[38;5;141m[lock]\x1b[0m release ${session.claudeSessionId}: ${e.message}`), + sw.sessionUnlock(session.workstreamId, sessionKey, HOLDER).catch((e) => + log(`\x1b[38;5;141m[lock]\x1b[0m release ${sessionKey}: ${e.message}`), ); } } /// Spawn a terminal for a session: a bash PTY in the checkout dir that runs -/// `command` (`claude` fresh, or `claude --resume `). Registered under -/// `sessionKey`. On process exit, releases the lock and drops the entry. +/// `command` (`claude --session-id ` fresh, or `claude --resume `). +/// Registered under `sessionKey` (the claude session id). On process exit, +/// releases the lock and drops the entry. export function spawnTerminal(params: { sessionKey: string; workstreamId: string; cwd: string; command: string; - claudeSessionId?: string; - resumed?: boolean; }): Session { - const { sessionKey, workstreamId, cwd, command, claudeSessionId, resumed } = params; + const { sessionKey, workstreamId, cwd, command } = params; const ptyProcess = spawnPty("/bin/bash", [], { name: "xterm-256color", cwd, - env: { - ...process.env, - TERM: "xterm-256color", - // The plugin echoes this back on status hooks so we can correlate. - OPENUI_SESSION_ID: sessionKey, - // Tell the plugin's status-reporter where the server actually is (its - // hardcoded fallback is a different port), so every status POST — which - // carries the Claude session id we record — is not silently dropped. - OPENUI_PORT: String(PORT), - OPENUI_HOST: HOST, - }, + env: { ...process.env, TERM: "xterm-256color" }, rows: 30, cols: 120, }); @@ -100,31 +59,12 @@ export function spawnTerminal(params: { cwd, clients: new Set(), outputBuffer: [], - status: "running", - lastOutputTime: Date.now(), - lastInputTime: 0, - recentOutputSize: 0, - claudeSessionId, - resumed: !!resumed, - // A resumed session's silverwood record + lock already exist, so the status - // hook must not re-record it. - silverwoodSessionRecorded: !!resumed, }; sessions.set(sessionKey, session); - const resetInterval = setInterval(() => { - if (sessions.get(sessionKey) !== session) { - clearInterval(resetInterval); - return; - } - session.recentOutputSize = Math.max(0, session.recentOutputSize - 50); - }, 500); - ptyProcess.onData((data: string) => { session.outputBuffer.push(data); if (session.outputBuffer.length > MAX_BUFFER_SIZE) session.outputBuffer.shift(); - session.lastOutputTime = Date.now(); - session.recentOutputSize += data.length; for (const client of session.clients) { if (client.readyState === 1) { client.send(JSON.stringify({ type: "output", data })); @@ -133,32 +73,25 @@ export function spawnTerminal(params: { }); ptyProcess.onExit(() => { - clearInterval(resetInterval); // Only tear down if this exact entry is still the live one for the key. if (sessions.get(sessionKey) === session) { - releaseLock(session); + releaseLock(sessionKey, session); session.clients.clear(); sessions.delete(sessionKey); log(`\x1b[38;5;141m[terminal]\x1b[0m exited ${sessionKey}`); } }); - const finalCommand = injectPluginDir(command, "claude"); - setTimeout(() => ptyProcess.write(`${finalCommand}\r`), 300); + setTimeout(() => ptyProcess.write(`${command}\r`), 300); log(`\x1b[38;5;141m[terminal]\x1b[0m spawned ${sessionKey} in ${cwd}`); return session; } -/// Resolve a runtime entry by its registry key, or by the claude session id it -/// carries (a fresh PTY is keyed by a provisional id but learns its claude id). +/// Resolve a runtime entry by its registry key (the claude session id). export function resolveRuntime(id: string): [string, Session] | undefined { - const direct = sessions.get(id); - if (direct) return [id, direct]; - for (const [key, s] of sessions) { - if (s.claudeSessionId === id) return [key, s]; - } - return undefined; + const s = sessions.get(id); + return s ? [id, s] : undefined; } /// Tear down a session's terminal + registry entry, releasing its lock. Does NOT @@ -166,7 +99,7 @@ export function resolveRuntime(id: string): [string, Session] | undefined { export function killTerminal(sessionKey: string): boolean { const session = sessions.get(sessionKey); if (!session) return false; - releaseLock(session); + releaseLock(sessionKey, session); session.pty.kill(); session.clients.clear(); sessions.delete(sessionKey); diff --git a/experimental/papyrus/server/types/index.ts b/experimental/papyrus/server/types/index.ts index 21a2b80..8004548 100644 --- a/experimental/papyrus/server/types/index.ts +++ b/experimental/papyrus/server/types/index.ts @@ -1,47 +1,21 @@ import type { IPty } from "bun-pty"; import type { ServerWebSocket } from "bun"; -export type AgentStatus = - | "running" - | "waiting_input" - | "tool_calling" - | "idle" - | "disconnected" - | "error"; - // A live-PTY registry entry — the ONE thing papyrus owns that silverwood cannot -// model (a running process, its clients, scrollback, and live status). Purely +// model (a running process, its WebSocket clients, and scrollback). Purely // ephemeral: an entry exists iff a PTY is live. All durable data (session list, // names, lock) is re-read from silverwood on demand — never cached here. Keyed in -// the registry by the runtime session key (the claude session id for a resumed -// session; a provisional uuid for a fresh one until the plugin reports its id). +// the registry by the claude session id: papyrus mints it via `--session-id` (or +// resumes via `--resume `), so the runtime key === claude id === silverwood key. export interface Session { pty: IPty; - // The workstream this PTY belongs to (the registry is no longer keyed by it). + // The workstream this PTY belongs to (the registry is keyed by session id). workstreamId: string; cwd: string; clients: Set>; outputBuffer: string[]; - status: AgentStatus; - lastOutputTime: number; - lastInputTime: number; - recentOutputSize: number; - // The claude session id: known up front for a resumed session, learned from the - // plugin hook for a fresh one. Used for reconciliation, recording, and the lock. - claudeSessionId?: string; - // Spawned via `--resume` (its silverwood session + lock already exist). - resumed: boolean; - // Whether a silverwood session has been recorded for claudeSessionId already. - silverwoodSessionRecorded: boolean; - // Whether this instance currently holds the advisory lock for claudeSessionId. + // Whether this instance currently holds the session's advisory lock. holdsLock?: boolean; - // Live status reported by the Claude Code plugin's hooks. - currentTool?: string; - preToolTime?: number; - permissionTimeout?: ReturnType; - pluginReportedStatus?: boolean; - lastPluginStatusTime?: number; - lastHookEvent?: string; } export interface LinearTicket { -- 2.54.0