From 45b7cfb3d3d4d3ac58ce36d46a2d138850ac022d Mon Sep 17 00:00:00 2001 From: Bryan Date: Fri, 16 Jan 2026 15:43:44 -0600 Subject: [PATCH 01/15] feat(orchestration): Add orchestration and Bryan integration modules Port orchestration capabilities from Claude Code integration: - Ralph Loop: Continuous execution until DONE marker - Background Agents: Spawn oracle/librarian/explore/research/analyze/implement - Complexity Detection: Auto-detect task complexity with word boundaries - Model Aliases: 20+ aliases covering Anthropic, OpenAI, DeepSeek, Google, Groq, Mistral Add Bryan dialectic integration for human-in-loop workflows: - Question/answer management via .bryan/ directory - Continuation processing for answered questions - Group-based routing (philosophical-union, groundwork-guild, integration-assembly) - Type-safe events using Zod schemas Key features: - Uses OpenCode's Storage API with built-in locking - Proper typed events via BusEvent.define() - Integrates with Instance context and Event Bus Co-Authored-By: Claude Opus 4.5 --- packages/opencode/src/bryan/continuation.ts | 206 ++++++++++ packages/opencode/src/bryan/dialectic.ts | 342 ++++++++++++++++ packages/opencode/src/bryan/events.ts | 55 +++ packages/opencode/src/bryan/index.ts | 92 +++++ .../opencode/src/orchestration/background.ts | 379 ++++++++++++++++++ .../opencode/src/orchestration/complexity.ts | 266 ++++++++++++ packages/opencode/src/orchestration/events.ts | 75 ++++ packages/opencode/src/orchestration/index.ts | 146 +++++++ .../opencode/src/orchestration/ralph-loop.ts | 202 ++++++++++ 9 files changed, 1763 insertions(+) create mode 100644 packages/opencode/src/bryan/continuation.ts create mode 100644 packages/opencode/src/bryan/dialectic.ts create mode 100644 packages/opencode/src/bryan/events.ts create mode 100644 packages/opencode/src/bryan/index.ts create mode 100644 packages/opencode/src/orchestration/background.ts create mode 100644 packages/opencode/src/orchestration/complexity.ts create mode 100644 packages/opencode/src/orchestration/events.ts create mode 100644 packages/opencode/src/orchestration/index.ts create mode 100644 packages/opencode/src/orchestration/ralph-loop.ts diff --git a/packages/opencode/src/bryan/continuation.ts b/packages/opencode/src/bryan/continuation.ts new file mode 100644 index 00000000000..55aadf80d44 --- /dev/null +++ b/packages/opencode/src/bryan/continuation.ts @@ -0,0 +1,206 @@ +/** + * Continuation Processing + * + * Handles automatic continuation checking and processing on session start. + * Integrates with the dialectic system to spawn background agents for + * answered questions. + * + * Ported from: sisyphean-works/bootstrap/tools/continue.py + */ + +import { Dialectic } from "./dialectic" +import { BackgroundAgent } from "../orchestration/background" +import { Bus } from "../bus" +import { Log } from "../util/log" +import { BryanEvents } from "./events" + +const log = Log.create({ service: "bryan-continuation" }) + +export namespace Continuation { + /** + * Result of continuation check + */ + export interface CheckResult { + hasContinuations: boolean + continuations: Dialectic.Continuation[] + prompt?: string + } + + /** + * Map groups to agent types + */ + const GROUP_TO_AGENT: Record = { + "philosophical-union": "analyze", + "groundwork-guild": "implement", + "integration-assembly": "research", + } + + /** + * Check for pending continuations on session start + * + * This should be called at the beginning of each session to process + * any answers Bryan provided while the system was idle. + */ + export async function checkOnStart(): Promise { + // First, check for any newly answered questions + await Dialectic.checkAnswers() + + // Get pending continuations + const continuations = await Dialectic.getPendingContinuations() + + if (continuations.length === 0) { + return { + hasContinuations: false, + continuations: [], + } + } + + log.info("Found pending continuations", { count: continuations.length }) + + // Build combined prompt for session awareness + const prompt = buildSessionPrompt(continuations) + + return { + hasContinuations: true, + continuations, + prompt, + } + } + + /** + * Build a session prompt that informs about pending continuations + */ + function buildSessionPrompt(continuations: Dialectic.Continuation[]): string { + const groupedByGroup = new Map() + + for (const cont of continuations) { + const existing = groupedByGroup.get(cont.group) ?? [] + existing.push(cont) + groupedByGroup.set(cont.group, existing) + } + + let prompt = `# Pending Continuations from Bryan + +Bryan has answered ${continuations.length} question(s) that require processing. + +` + + for (const [group, conts] of groupedByGroup) { + prompt += `## ${group} (${conts.length} continuation(s)) + +` + for (const cont of conts) { + prompt += `### ${cont.id} +${cont.prompt.slice(0, 500)}... + +` + } + } + + prompt += `## Recommended Action + +Launch background agents to process these continuations: +- Use \`spawn_continuation\` for each group +- Or process inline if the continuations are simple + +Do NOT ignore Bryan's answers. They represent human guidance.` + + return prompt + } + + /** + * Spawn background agents to process continuations + */ + export async function spawnProcessors(): Promise { + const continuations = await Dialectic.getPendingContinuations() + const tasks: BackgroundAgent.Task[] = [] + + // Group by group to spawn one agent per group + const groupedByGroup = new Map() + + for (const cont of continuations) { + const existing = groupedByGroup.get(cont.group) ?? [] + existing.push(cont) + groupedByGroup.set(cont.group, existing) + } + + for (const [group, conts] of groupedByGroup) { + const combinedPrompt = conts.map((c) => c.prompt).join("\n\n---\n\n") + + const task = await BackgroundAgent.spawn({ + task: combinedPrompt, + agentType: GROUP_TO_AGENT[group], + context: { + group, + continuationIds: conts.map((c) => c.id), + isDialecticContinuation: true, + }, + }) + + tasks.push(task) + + // Mark as processed (they're being handled by the spawned agent) + for (const cont of conts) { + await Dialectic.markProcessed(cont.id) + } + + log.info("Spawned continuation processor", { + group, + taskId: task.id, + continuationCount: conts.length, + }) + } + + Bus.publish(BryanEvents.ContinuationsSpawned, { + taskCount: tasks.length, + totalContinuations: continuations.length, + }) + + return tasks + } + + /** + * Process a single continuation inline (without spawning) + */ + export async function processInline(continuationId: string): Promise { + const continuations = await Dialectic.getPendingContinuations() + const continuation = continuations.find((c) => c.id === continuationId) + + if (!continuation) { + throw new Error(`Continuation not found: ${continuationId}`) + } + + // Mark as processed + await Dialectic.markProcessed(continuationId) + + // Return the prompt for the session to handle + return continuation.prompt + } + + /** + * Get status message for session start + */ + export async function getStatusMessage(): Promise { + const status = await Dialectic.getStatus() + + if (status.pendingContinuations === 0 && status.pendingQuestions === 0) { + return undefined + } + + let message = "**Bryan Integration Status**\n\n" + + if (status.pendingContinuations > 0) { + message += `- ${status.pendingContinuations} continuation(s) pending (Bryan answered)\n` + } + + if (status.pendingQuestions > 0) { + message += `- ${status.pendingQuestions} question(s) awaiting Bryan's answer\n` + } + + if (status.pendingContinuations > 0) { + message += "\nRun `spawn_continuation` to process Bryan's answers." + } + + return message + } +} diff --git a/packages/opencode/src/bryan/dialectic.ts b/packages/opencode/src/bryan/dialectic.ts new file mode 100644 index 00000000000..94b91402ff2 --- /dev/null +++ b/packages/opencode/src/bryan/dialectic.ts @@ -0,0 +1,342 @@ +/** + * Bryan Dialectic Integration + * + * Implements the human-in-loop dialectic system for OpenCode. + * Manages question/answer workflows between the AI system and Bryan. + * + * Features: + * - Question file management (.bryan/ directory) + * - Answer detection and processing + * - Continuation signal generation + * - Group-based routing (Philosophical Union, Groundwork Guild, Integration Assembly) + * + * Ported from: sisyphean-works/bootstrap/tools/dialectic.py + */ + +import { Instance } from "../project/instance" +import { Storage } from "../storage/storage" +import { Bus } from "../bus" +import { Log } from "../util/log" +import { BryanEvents } from "./events" +import path from "path" +import fs from "fs/promises" +import YAML from "yaml" + +const log = Log.create({ service: "bryan-dialectic" }) + +export namespace Dialectic { + /** + * Dialectic question structure + */ + export interface Question { + id: string + question: string + context?: string + group: Group + priority: "low" | "medium" | "high" | "critical" + ready: boolean + answer?: string + createdAt: string + answeredAt?: string + } + + /** + * Groups that can receive questions + */ + export type Group = "philosophical-union" | "groundwork-guild" | "integration-assembly" + + /** + * Continuation signal for answered questions + */ + export interface Continuation { + id: string + questionId: string + group: Group + prompt: string + createdAt: string + processedAt?: string + } + + /** + * Storage keys + */ + const QUESTIONS_KEY = ["bryan", "questions"] + const CONTINUATIONS_KEY = ["bryan", "continuations"] + + /** + * Get the .bryan directory path + */ + async function getBryanDir(): Promise { + const bryanPath = path.join(Instance.directory, ".bryan") + await fs.mkdir(bryanPath, { recursive: true }) + return bryanPath + } + + /** + * Generate a unique question ID + */ + function generateQuestionId(group: Group): string { + const timestamp = new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 14) + const shortGroup = group.split("-")[0].slice(0, 4) + return `${shortGroup}-${timestamp}` + } + + /** + * Create a new question for Bryan + */ + export async function ask(options: { + question: string + context?: string + group: Group + priority?: "low" | "medium" | "high" | "critical" + }): Promise { + const bryanDir = await getBryanDir() + const id = generateQuestionId(options.group) + + const question: Question = { + id, + question: options.question, + context: options.context, + group: options.group, + priority: options.priority ?? "medium", + ready: false, + createdAt: new Date().toISOString(), + } + + // Write YAML file for Bryan to answer + const yamlContent = YAML.stringify({ + id: question.id, + question: question.question, + context: question.context, + group: question.group, + priority: question.priority, + ready: false, + answer: "", + created_at: question.createdAt, + }) + + const filePath = path.join(bryanDir, `${id}.yaml`) + await fs.writeFile(filePath, yamlContent) + + // Store in persistent storage + try { + await Storage.update>(QUESTIONS_KEY, (draft) => { + Object.assign(draft, { [id]: question }) + }) + } catch { + // Storage doesn't exist yet, write initial state + await Storage.write(QUESTIONS_KEY, { [id]: question }) + } + + log.info("Question created for Bryan", { id, group: options.group }) + + Bus.publish(BryanEvents.QuestionCreated, { + questionId: id, + group: options.group, + priority: question.priority, + }) + + return question + } + + /** + * Check for answered questions in .bryan/ directory + */ + export async function checkAnswers(): Promise { + const bryanDir = await getBryanDir() + const answered: Question[] = [] + + try { + const files = await fs.readdir(bryanDir) + const yamlFiles = files.filter((f) => f.endsWith(".yaml")) + + for (const file of yamlFiles) { + try { + const filePath = path.join(bryanDir, file) + const content = await fs.readFile(filePath, "utf-8") + const data = YAML.parse(content) + + if (data.ready === true && data.answer) { + // Update storage + let updatedQuestion: Question | undefined + try { + const result = await Storage.update>(QUESTIONS_KEY, (draft) => { + if (draft && draft[data.id]) { + draft[data.id].ready = true + draft[data.id].answer = data.answer + draft[data.id].answeredAt = new Date().toISOString() + } + }) + updatedQuestion = result?.[data.id] + } catch { + // No storage yet + } + + if (updatedQuestion) { + answered.push(updatedQuestion) + // Create continuation signal + await createContinuation(updatedQuestion) + } + } + } catch (err) { + log.warn("Failed to parse question file", { file, error: String(err) }) + } + } + } catch (err) { + log.warn("Failed to read .bryan directory", { error: String(err) }) + } + + return answered + } + + /** + * Create a continuation signal for an answered question + */ + async function createContinuation(question: Question): Promise { + const id = `cont-${question.id}` + + const prompt = buildContinuationPrompt(question) + + const continuation: Continuation = { + id, + questionId: question.id, + group: question.group, + prompt, + createdAt: new Date().toISOString(), + } + + try { + await Storage.update>(CONTINUATIONS_KEY, (draft) => { + Object.assign(draft, { [id]: continuation }) + }) + } catch { + // Storage doesn't exist yet, write initial state + await Storage.write(CONTINUATIONS_KEY, { [id]: continuation }) + } + + log.info("Continuation created", { id, questionId: question.id }) + + Bus.publish(BryanEvents.ContinuationCreated, { + continuationId: id, + questionId: question.id, + group: question.group, + }) + + return continuation + } + + /** + * Build the continuation prompt for a group + */ + function buildContinuationPrompt(question: Question): string { + const groupDescriptions: Record = { + "philosophical-union": `You are continuing deliberation as the Philosophical Union. +Bryan has answered a question from your previous session.`, + "groundwork-guild": `You are continuing work as the Groundwork Guild. +Bryan has provided guidance on a practical matter.`, + "integration-assembly": `You are continuing coordination as the Integration Assembly. +Bryan has answered a question about system integration.`, + } + + return `# Continuation: ${question.group} + +${groupDescriptions[question.group]} + +## Original Question +${question.question} + +${question.context ? `## Context\n${question.context}\n` : ""} + +## Bryan's Answer +${question.answer} + +## Instructions +1. Process Bryan's answer in the context of your ongoing work +2. Update any relevant documentation or state +3. Continue with the next steps based on this guidance +4. If further clarification is needed, create a new question + +Do NOT re-ask the same question. Bryan has spoken.` + } + + /** + * Get pending continuations that haven't been processed + */ + export async function getPendingContinuations(): Promise { + try { + const continuations = (await Storage.read>(CONTINUATIONS_KEY)) ?? {} + return Object.values(continuations).filter((c) => !c.processedAt) + } catch { + return [] + } + } + + /** + * Mark a continuation as processed + */ + export async function markProcessed(continuationId: string): Promise { + try { + await Storage.update>(CONTINUATIONS_KEY, (draft) => { + if (draft && draft[continuationId]) { + draft[continuationId].processedAt = new Date().toISOString() + } + }) + log.info("Continuation marked as processed", { continuationId }) + } catch (err) { + log.warn("Failed to mark continuation as processed", { continuationId, error: String(err) }) + } + } + + /** + * Get all questions (for status display) + */ + export async function getQuestions(): Promise> { + try { + return (await Storage.read>(QUESTIONS_KEY)) ?? {} + } catch { + return {} + } + } + + /** + * Get status summary + */ + export async function getStatus(): Promise<{ + pendingQuestions: number + answeredQuestions: number + pendingContinuations: number + processedContinuations: number + }> { + const questions = await getQuestions() + const continuations = (await Storage.read>(CONTINUATIONS_KEY)) ?? {} + + const questionsList = Object.values(questions) + const continuationsList = Object.values(continuations) + + return { + pendingQuestions: questionsList.filter((q) => !q.ready).length, + answeredQuestions: questionsList.filter((q) => q.ready).length, + pendingContinuations: continuationsList.filter((c) => !c.processedAt).length, + processedContinuations: continuationsList.filter((c) => c.processedAt).length, + } + } + + /** + * Archive a processed question (move to archive) + */ + export async function archiveQuestion(questionId: string): Promise { + const bryanDir = await getBryanDir() + const archiveDir = path.join(bryanDir, "archive") + await fs.mkdir(archiveDir, { recursive: true }) + + const sourceFile = path.join(bryanDir, `${questionId}.yaml`) + const destFile = path.join(archiveDir, `${questionId}.yaml`) + + try { + await fs.rename(sourceFile, destFile) + log.info("Question archived", { questionId }) + } catch (err) { + log.warn("Failed to archive question", { questionId, error: String(err) }) + } + } +} diff --git a/packages/opencode/src/bryan/events.ts b/packages/opencode/src/bryan/events.ts new file mode 100644 index 00000000000..622432861cd --- /dev/null +++ b/packages/opencode/src/bryan/events.ts @@ -0,0 +1,55 @@ +/** + * Bryan Integration Event Definitions + * + * Type-safe event definitions for Bryan dialectic integration using OpenCode's + * BusEvent system with Zod schemas. + */ + +import z from "zod" +import { BusEvent } from "../bus/bus-event" + +export namespace BryanEvents { + // Question events + export const QuestionCreated = BusEvent.define( + "bryan.question.created", + z.object({ + questionId: z.string(), + group: z.enum(["philosophical-union", "groundwork-guild", "integration-assembly"]), + priority: z.enum(["low", "medium", "high", "critical"]), + }) + ) + + export const QuestionAnswered = BusEvent.define( + "bryan.question.answered", + z.object({ + questionId: z.string(), + group: z.enum(["philosophical-union", "groundwork-guild", "integration-assembly"]), + }) + ) + + // Continuation events + export const ContinuationCreated = BusEvent.define( + "bryan.continuation.created", + z.object({ + continuationId: z.string(), + questionId: z.string(), + group: z.enum(["philosophical-union", "groundwork-guild", "integration-assembly"]), + }) + ) + + export const ContinuationProcessed = BusEvent.define( + "bryan.continuation.processed", + z.object({ + continuationId: z.string(), + processedBy: z.enum(["inline", "background-agent"]), + }) + ) + + export const ContinuationsSpawned = BusEvent.define( + "bryan.continuations.spawned", + z.object({ + taskCount: z.number(), + totalContinuations: z.number(), + }) + ) +} diff --git a/packages/opencode/src/bryan/index.ts b/packages/opencode/src/bryan/index.ts new file mode 100644 index 00000000000..66c0b006592 --- /dev/null +++ b/packages/opencode/src/bryan/index.ts @@ -0,0 +1,92 @@ +/** + * Bryan Integration Module + * + * Provides human-in-loop dialectic capabilities for OpenCode: + * - Question/Answer workflow with Bryan + * - Continuation processing for answered questions + * - Group-based routing (Philosophical Union, Groundwork Guild, Integration Assembly) + * + * Ported from: sisyphean-works/bootstrap/tools/dialectic.py, continue.py + */ + +export { Dialectic } from "./dialectic" +export { Continuation } from "./continuation" +export { BryanEvents } from "./events" + +import { Dialectic } from "./dialectic" +import { Continuation } from "./continuation" +import { Log } from "../util/log" + +const log = Log.create({ service: "bryan" }) + +/** + * Initialize Bryan integration on session start + * + * This should be called at the beginning of each session to: + * 1. Check for answered questions + * 2. Report pending continuations + * 3. Optionally spawn processors + */ +export async function initializeBryan(): Promise<{ + statusMessage?: string + hasContinuations: boolean +}> { + log.info("Initializing Bryan integration") + + // Check for continuations + const result = await Continuation.checkOnStart() + + // Get status message + const statusMessage = await Continuation.getStatusMessage() + + if (result.hasContinuations) { + log.info("Bryan has answered questions - continuations pending", { + count: result.continuations.length, + }) + } + + return { + statusMessage, + hasContinuations: result.hasContinuations, + } +} + +/** + * Quick helpers for common operations + */ +export const Bryan = { + /** + * Ask Bryan a question + */ + ask: Dialectic.ask, + + /** + * Get current status + */ + status: Dialectic.getStatus, + + /** + * Check for answers and create continuations + */ + checkAnswers: Dialectic.checkAnswers, + + /** + * Spawn background agents for pending continuations + */ + spawnContinuations: Continuation.spawnProcessors, + + /** + * Process a specific continuation inline + */ + processContinuation: Continuation.processInline, + + /** + * Get all pending questions + */ + getQuestions: Dialectic.getQuestions, + + /** + * Archive a processed question + */ + archiveQuestion: Dialectic.archiveQuestion, +} diff --git a/packages/opencode/src/orchestration/background.ts b/packages/opencode/src/orchestration/background.ts new file mode 100644 index 00000000000..ed5550bc0ef --- /dev/null +++ b/packages/opencode/src/orchestration/background.ts @@ -0,0 +1,379 @@ +/** + * Background Agent Spawning + * + * Enables spawning background agents that run asynchronously while + * the main session continues. Integrates with OpenCode's agent system + * and event bus for status tracking. + * + * Agent Types: + * - oracle: Deep research specialist + * - librarian: Documentation and memory specialist + * - explore: Codebase exploration specialist + * - research: General research agent + * - analyze: Analysis tasks + * - implement: Implementation tasks + * + * Ported from: scripts/spawn_background_agent.py + * Uses: OpenCode's Agent system, Storage API, Event Bus + */ + +import { Storage } from "../storage/storage" +import { Bus } from "../bus" +import { Instance } from "../project/instance" +import { Log } from "../util/log" +import { OrchestrationEvents } from "./events" +import path from "path" +import fs from "fs/promises" + +const log = Log.create({ service: "background-agent" }) + +export namespace BackgroundAgent { + /** + * Agent types with their default configurations + */ + export type AgentType = "oracle" | "librarian" | "explore" | "research" | "analyze" | "implement" + + /** + * Background task state + */ + export interface Task { + id: string + agentType: AgentType + description: string + triggerFile: string + status: "pending" | "running" | "completed" | "failed" | "reported" + startedAt: string + completedAt?: string + output?: string + error?: string + } + + /** + * Spawn options + */ + export interface SpawnOptions { + task: string + agentType?: AgentType + model?: string + context?: Record + testMode?: boolean + } + + /** + * Model aliases for convenience - includes models from both Claude Code and OpenCode + */ + export const MODEL_ALIASES: Record = { + // Anthropic models + sonnet: "claude-sonnet-4-20250514", + opus: "claude-opus-4-5-20251101", + haiku: "claude-3-5-haiku-20241022", + "claude-sonnet": "claude-sonnet-4-20250514", + "claude-opus": "claude-opus-4-5-20251101", + "claude-haiku": "claude-3-5-haiku-20241022", + // OpenAI models + "gpt-4": "gpt-4-turbo-preview", + "gpt-4o": "gpt-4o", + "gpt-4o-mini": "gpt-4o-mini", + o1: "o1", + "o1-mini": "o1-mini", + "o1-preview": "o1-preview", + "o3-mini": "o3-mini", + // DeepSeek models + deepseek: "deepseek-reasoner", + "deepseek-chat": "deepseek-chat", + "deepseek-coder": "deepseek-coder", + // Google models + gemini: "gemini-2.0-flash-thinking-exp-01-21", + "gemini-pro": "gemini-1.5-pro", + "gemini-flash": "gemini-1.5-flash", + // Groq models + llama: "llama-3.3-70b-versatile", + mixtral: "mixtral-8x7b-32768", + // Mistral models + mistral: "mistral-large-latest", + codestral: "codestral-latest", + } + + /** + * Storage key for background tasks + */ + const STORAGE_KEY = ["orchestration", "background-tasks"] + + /** + * Resolve model alias to full model name + */ + export function resolveModel(model: string): string { + return MODEL_ALIASES[model.toLowerCase()] ?? model + } + + /** + * Create agent prompt with context + */ + function createAgentPrompt(task: string, agentType: AgentType, context: Record): string { + const prompts: Record = { + oracle: `You are **Oracle** - a deep research agent spawned in the background. + +**Your Task:** ${task} + +**Context:** +- Spawned by: OpenCode session +- Parent session: ${context.sessionId ?? "unknown"} +- Timestamp: ${new Date().toISOString()} + +**Instructions:** +1. Conduct thorough research on the topic +2. Search the codebase, documentation, and web as needed +3. Write your findings to a markdown file in the appropriate location +4. Be thorough - this is background work, take your time +5. Include citations and references + +When complete, ensure your output is saved for the parent session to retrieve.`, + + librarian: `You are **Librarian** - a documentation and memory agent spawned in the background. + +**Your Task:** ${task} + +**Context:** +- Spawned by: OpenCode session +- Parent session: ${context.sessionId ?? "unknown"} +- Timestamp: ${new Date().toISOString()} + +**Instructions:** +1. Focus on organizing and documenting information +2. Use memories to store important findings +3. Create clear, structured documentation +4. Index and cross-reference relevant materials +5. Make information discoverable for future sessions + +When complete, ensure your documentation is properly saved and indexed.`, + + explore: `You are **Explorer** - a codebase exploration agent spawned in the background. + +**Your Task:** ${task} + +**Context:** +- Spawned by: OpenCode session +- Parent session: ${context.sessionId ?? "unknown"} +- Timestamp: ${new Date().toISOString()} + +**Instructions:** +1. Use search tools to thoroughly explore the codebase +2. Map relevant files, classes, functions, and relationships +3. Create a comprehensive report of your findings +4. Note any patterns, issues, or opportunities discovered +5. Save your findings for the parent session + +Be thorough in your exploration - cover multiple entry points and follow connections.`, + + research: `You are a **Research Agent** spawned in the background. + +**Your Task:** ${task} + +**Context:** +- Spawned by: OpenCode session +- Parent session: ${context.sessionId ?? "unknown"} +- Timestamp: ${new Date().toISOString()} + +**Instructions:** +1. Conduct comprehensive research on the topic +2. Use web search, documentation, and codebase search as appropriate +3. Synthesize findings into a clear report +4. Include sources and confidence levels +5. Save your findings for retrieval + +When complete, your output will be available to the parent session.`, + + analyze: `You are an **Analysis Agent** spawned in the background. + +**Your Task:** ${task} + +**Context:** +- Spawned by: OpenCode session +- Parent session: ${context.sessionId ?? "unknown"} +- Timestamp: ${new Date().toISOString()} + +**Instructions:** +1. Perform deep analysis of the specified topic +2. Consider multiple perspectives and trade-offs +3. Document your reasoning process +4. Provide actionable recommendations +5. Save your analysis for the parent session + +Be thorough and consider edge cases.`, + + implement: `You are an **Implementation Agent** spawned in the background. + +**Your Task:** ${task} + +**Context:** +- Spawned by: OpenCode session +- Parent session: ${context.sessionId ?? "unknown"} +- Timestamp: ${new Date().toISOString()} + +**Instructions:** +1. Implement the requested feature or fix +2. Follow existing code patterns and conventions +3. Write tests if appropriate +4. Document your changes +5. Commit with clear messages (if appropriate) + +When complete, report what was implemented and any issues encountered.`, + } + + return prompts[agentType] + } + + /** + * Get triggers directory + */ + async function getTriggersDir(): Promise { + const opencodePath = path.join(Instance.directory, ".opencode", "triggers") + await fs.mkdir(opencodePath, { recursive: true }) + return opencodePath + } + + /** + * Spawn a background agent + */ + export async function spawn(options: SpawnOptions): Promise { + const agentType = options.agentType ?? "research" + const model = resolveModel(options.model ?? "sonnet") + const context = options.context ?? {} + + // Generate task ID + const taskId = `spawn-${Date.now()}` + + // Get triggers directory + const triggersDir = await getTriggersDir() + const triggerFile = path.join(triggersDir, `${taskId}.trigger`) + + // Create prompt + const prompt = createAgentPrompt(options.task, agentType, context) + + // Create trigger content (JSON for security) + const triggerContent = { + prompt, + model, + variant: "medium", + metadata: { + agentType, + taskSummary: options.task.slice(0, 100), + spawnedBy: "opencode", + spawnedAt: new Date().toISOString(), + parentSession: context.sessionId ?? "", + }, + } + + // Write trigger file + await fs.writeFile(triggerFile, JSON.stringify(triggerContent, null, 2)) + + // Create task record + const task: Task = { + id: taskId, + agentType, + description: `[${agentType}] ${options.task.slice(0, 50)}...`, + triggerFile, + status: "pending", + startedAt: new Date().toISOString(), + } + + // Register in storage + try { + await Storage.update>(STORAGE_KEY, (draft) => { + Object.assign(draft, { [taskId]: task }) + }) + } catch { + // Storage doesn't exist yet, write initial state + await Storage.write(STORAGE_KEY, { [taskId]: task }) + } + + log.info("Background agent spawned", { + taskId, + agentType, + model, + }) + + Bus.publish(OrchestrationEvents.BackgroundAgentSpawned, { + taskId, + agentType, + model, + description: task.description, + }) + + return task + } + + /** + * Get all background tasks + */ + export async function getTasks(): Promise> { + try { + return (await Storage.read>(STORAGE_KEY)) ?? {} + } catch { + return {} + } + } + + /** + * Get completed tasks that haven't been reported + */ + export async function getCompletedTasks(): Promise { + const tasks = await getTasks() + const completed: Task[] = [] + + for (const task of Object.values(tasks)) { + const doneFile = task.triggerFile.replace(".trigger", ".trigger.done") + const outputFile = task.triggerFile.replace(".trigger", ".trigger.output") + + try { + const [doneExists, outputExists] = await Promise.all([ + fs.stat(doneFile).then(() => true).catch(() => false), + fs.stat(outputFile).then(() => true).catch(() => false), + ]) + + if ((doneExists || outputExists) && task.status !== "reported") { + if (outputExists) { + try { + const output = await fs.readFile(outputFile, "utf-8") + task.output = output.slice(0, 2000) + } catch { + task.output = "(output file exists but unreadable)" + } + } + task.status = "completed" + completed.push(task) + } + } catch { + // File doesn't exist, task still running + } + } + + return completed + } + + /** + * Mark a task as reported + */ + export async function markReported(taskId: string): Promise { + try { + await Storage.update>(STORAGE_KEY, (draft) => { + if (draft && draft[taskId]) { + draft[taskId].status = "reported" + draft[taskId].completedAt = new Date().toISOString() + } + }) + } catch (err) { + log.warn("Failed to mark task as reported", { taskId, error: String(err) }) + } + } + + /** + * List available models with aliases + */ + export function listModels(): Array<{ alias: string; fullName: string }> { + return Object.entries(MODEL_ALIASES).map(([alias, fullName]) => ({ + alias, + fullName, + })) + } +} diff --git a/packages/opencode/src/orchestration/complexity.ts b/packages/opencode/src/orchestration/complexity.ts new file mode 100644 index 00000000000..b20bf7aa762 --- /dev/null +++ b/packages/opencode/src/orchestration/complexity.ts @@ -0,0 +1,266 @@ +/** + * Task Complexity Detection + * + * Estimates task complexity to decide orchestration strategy. + * Uses a hybrid approach combining: + * - Programmatic signal detection (word boundaries) + * - Question detection (to reduce false positives) + * - Weighted scoring (strong vs weak signals) + * + * Inspired by OpenCode's phase-based approach combined with + * programmatic detection from our Python implementation. + * + * Ported from: scripts/cli_context_inject.py + */ + +import { Log } from "../util/log" + +const log = Log.create({ service: "complexity" }) + +export namespace Complexity { + /** + * Complexity levels that determine orchestration behavior + */ + export type Level = "simple" | "moderate" | "complex" | "research" + + /** + * Result of complexity estimation + */ + export interface EstimateResult { + level: Level + score: number + signals: string[] + isQuestion: boolean + } + + /** + * Check if signal matches with word boundaries + */ + function wordBoundaryMatch(signal: string, text: string): boolean { + const pattern = new RegExp(`\\b${signal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i") + return pattern.test(text) + } + + /** + * Detect if prompt is a simple question that shouldn't trigger orchestration + */ + function isSimpleQuestion(prompt: string): boolean { + const lower = prompt.toLowerCase().trim() + + // Simple question starters + const questionStarters = [ + "is ", + "are ", + "does ", + "do ", + "can ", + "could ", + "would ", + "what is ", + "what are ", + "where is ", + "where are ", + "how is ", + "why is ", + "when is ", + "which ", + "tell me about ", + "explain ", + ] + + // Check if it starts with a question word + if (questionStarters.some((q) => lower.startsWith(q))) { + // But not if it's asking to DO something + const actionInQuestion = [ + "how do i ", + "how can i ", + "how should i ", + "can you ", + "could you ", + "would you ", + ] + if (!actionInQuestion.some((a) => lower.startsWith(a))) { + return true + } + } + + // Check for question mark at end with short length + if (prompt.trim().endsWith("?") && prompt.split(/\s+/).length < 15) { + return true + } + + return false + } + + /** + * Estimate task complexity with detailed scoring + */ + export function estimate(prompt: string): EstimateResult { + const lower = prompt.toLowerCase() + const signals: string[] = [] + let score = 0 + + // Simple questions don't need orchestration + const isQuestion = isSimpleQuestion(prompt) + if (isQuestion) { + return { + level: "simple", + score: 0, + signals: ["simple_question"], + isQuestion: true, + } + } + + // Research indicators (strong signals) + const researchSignals = [ + "research", + "investigate", + "analyze", + "explore", + "comprehensive", + "deep dive", + "survey", + "audit", + ] + const researchBoundarySignals = ["review", "understand"] + + for (const s of researchSignals) { + if (lower.includes(s)) { + score += 2 + signals.push(`research:${s}`) + } + } + + for (const s of researchBoundarySignals) { + if (wordBoundaryMatch(s, lower)) { + score += 1.5 + signals.push(`research_boundary:${s}`) + } + } + + if (score >= 1.5) { + return { + level: "research", + score, + signals, + isQuestion: false, + } + } + + // Complex task indicators + const strongComplexSignals = [ + "implement", + "refactor", + "migrate", + "integrate", + "create a system", + "architect", + "redesign", + ] + const weakComplexSignals = [ + "build", + "design", + "multiple", + "entire", + "comprehensive", + "parallel", + "background", + ] + const veryWeakSignals = ["all", "complete", "full", "across", "everything"] + + for (const s of strongComplexSignals) { + if (lower.includes(s)) { + score += 1.5 + signals.push(`strong:${s}`) + } + } + + for (const s of weakComplexSignals) { + if (wordBoundaryMatch(s, lower)) { + score += 0.7 + signals.push(`weak:${s}`) + } + } + + for (const s of veryWeakSignals) { + if (wordBoundaryMatch(s, lower)) { + score += 0.3 + signals.push(`very_weak:${s}`) + } + } + + // Multi-step indicators + const multistepSignals = [" then ", "first,", "after that", "finally,", "step ", "phase "] + const listIndicators = ["1.", "2.", "- "] + + for (const s of multistepSignals) { + if (lower.includes(s)) { + score += 0.8 + signals.push(`multistep:${s.trim()}`) + } + } + + for (const s of listIndicators) { + if (prompt.includes(s)) { + score += 0.4 + signals.push(`list:${s}`) + } + } + + // Length as complexity proxy (but less weight) + const wordCount = prompt.split(/\s+/).length + if (wordCount > 60) { + score += 0.5 + signals.push(`long:${wordCount}`) + } + + // Determine level from score + let level: Level + if (score >= 1.5) { + level = "complex" + } else if (score >= 0.7) { + level = "moderate" + } else { + level = "simple" + } + + log.debug("Complexity estimated", { level, score, signals: signals.length }) + + return { + level, + score, + signals, + isQuestion: false, + } + } + + /** + * Quick check if prompt should trigger orchestration awareness + */ + export function shouldInjectOrchestration(prompt: string): boolean { + const result = estimate(prompt) + return result.level === "complex" || result.level === "research" + } + + /** + * Get recommended parallelism level based on complexity + * + * Based on OpenCode's phase-based approach: + * - Low: 1 agent (single file, isolated task) + * - Medium: 2 agents (2-5 files) + * - High: 3 agents (5+ files or uncertain scope) + */ + export function recommendedParallelism(level: Level): number { + switch (level) { + case "simple": + return 1 + case "moderate": + return 2 + case "complex": + case "research": + return 3 + default: + return 1 + } + } +} diff --git a/packages/opencode/src/orchestration/events.ts b/packages/opencode/src/orchestration/events.ts new file mode 100644 index 00000000000..c2dc1bd2246 --- /dev/null +++ b/packages/opencode/src/orchestration/events.ts @@ -0,0 +1,75 @@ +/** + * Orchestration Event Definitions + * + * Type-safe event definitions for orchestration features using OpenCode's + * BusEvent system with Zod schemas. + */ + +import z from "zod" +import { BusEvent } from "../bus/bus-event" + +export namespace OrchestrationEvents { + // Ralph Loop events + export const RalphLoopStarted = BusEvent.define( + "orchestration.ralph_loop.started", + z.object({ + maxIterations: z.number(), + sessionId: z.string(), + }) + ) + + export const RalphLoopIteration = BusEvent.define( + "orchestration.ralph_loop.iteration", + z.object({ + iteration: z.number(), + maxIterations: z.number(), + }) + ) + + export const RalphLoopStopped = BusEvent.define( + "orchestration.ralph_loop.stopped", + z.object({ + reason: z.string(), + iterationsCompleted: z.number(), + }) + ) + + // Background agent events + export const BackgroundAgentSpawned = BusEvent.define( + "orchestration.background_agent.spawned", + z.object({ + taskId: z.string(), + agentType: z.string(), + model: z.string(), + description: z.string(), + }) + ) + + export const BackgroundAgentCompleted = BusEvent.define( + "orchestration.background_agent.completed", + z.object({ + taskId: z.string(), + agentType: z.string(), + outputLength: z.number().optional(), + }) + ) + + export const BackgroundAgentFailed = BusEvent.define( + "orchestration.background_agent.failed", + z.object({ + taskId: z.string(), + agentType: z.string(), + error: z.string(), + }) + ) + + // Complexity detection events + export const ComplexityDetected = BusEvent.define( + "orchestration.complexity.detected", + z.object({ + level: z.enum(["simple", "moderate", "complex", "research"]), + score: z.number(), + signalCount: z.number(), + }) + ) +} diff --git a/packages/opencode/src/orchestration/index.ts b/packages/opencode/src/orchestration/index.ts new file mode 100644 index 00000000000..c77dacfb930 --- /dev/null +++ b/packages/opencode/src/orchestration/index.ts @@ -0,0 +1,146 @@ +/** + * Orchestration Module + * + * Provides enhanced orchestration capabilities for OpenCode: + * - Ralph Loop: Continuous execution until completion marker + * - Background Agents: Async task delegation + * - Complexity Detection: Automatic orchestration strategy selection + * + * Ported from Claude Code orchestration integration (2026-01-16) + * Based on analysis of opencode architecture and our requirements. + */ + +export { RalphLoop } from "./ralph-loop" +export { BackgroundAgent } from "./background" +export { Complexity } from "./complexity" +export { OrchestrationEvents } from "./events" + +import { RalphLoop } from "./ralph-loop" +import { BackgroundAgent } from "./background" +import { Complexity } from "./complexity" +import { Log } from "../util/log" + +const log = Log.create({ service: "orchestration" }) + +/** + * Orchestration capabilities that can be injected into prompts + */ +export function getOrchestrationCapabilities(): string { + return ` +## Available Orchestration Capabilities + +You have access to enhanced orchestration patterns. Use them when appropriate: + +### 1. Background Agents +For tasks that can run asynchronously while you continue other work. +Available agent types: oracle (research), librarian (docs), explore (codebase), research, analyze, implement + +### 2. Continuous Execution (Ralph Loop) +For tasks that MUST complete fully. Include \`DONE\` when truly complete. +The system will re-prompt you if you stop without the completion marker. + +### 3. Parallel Agent Pattern +When you have independent subtasks, launch multiple Task agents in a SINGLE message. +- Identify independent subtasks +- Use Task tool multiple times in one response +- Aggregate results + +### 4. Todo Enforcement +If you create todos with TodoWrite, you'll be prompted to complete them if you try to stop with pending items. + +**When to use these:** +- Complex multi-file changes → Consider parallel agents +- Research that takes time → Spawn background oracle +- Must-complete tasks → Activate Ralph Loop +- Documentation tasks → Spawn librarian + +Use your judgment - these are tools, not requirements. +` +} + +/** + * Research-specific capabilities + */ +export function getResearchCapabilities(): string { + return ` +### Research-Specific Tools + +For this research task, consider: +1. **Spawn Oracle**: Background agent for deep research +2. **Use Explore agent**: Task tool for codebase investigation +3. **Web search**: For external documentation/context + +Take time for thorough investigation before conclusions. +` +} + +/** + * Check if orchestration awareness should be injected based on prompt complexity + */ +export function shouldInjectAwareness(prompt: string): { + inject: boolean + capabilities: string + complexity: Complexity.Level +} { + const result = Complexity.estimate(prompt) + + if (result.level === "complex" || result.level === "research") { + let capabilities = getOrchestrationCapabilities() + + if (result.level === "research") { + capabilities += getResearchCapabilities() + } + + return { + inject: true, + capabilities, + complexity: result.level, + } + } + + return { + inject: false, + capabilities: "", + complexity: result.level, + } +} + +/** + * Process orchestration hooks on session stop + */ +export async function processStopHooks(transcriptSummary: string): Promise<{ + continuePrompt?: string + completedTasks?: BackgroundAgent.Task[] +}> { + const result: { + continuePrompt?: string + completedTasks?: BackgroundAgent.Task[] + } = {} + + // Check Ralph Loop continuation + const ralphResult = await RalphLoop.checkContinuation(transcriptSummary) + if (ralphResult.shouldContinue && ralphResult.prompt) { + result.continuePrompt = ralphResult.prompt + return result + } + + // Check for completed background tasks + const completedTasks = await BackgroundAgent.getCompletedTasks() + if (completedTasks.length > 0) { + result.completedTasks = completedTasks + + // Mark them as reported + for (const task of completedTasks) { + await BackgroundAgent.markReported(task.id) + } + } + + return result +} + +/** + * Initialize orchestration module + */ +export async function initialize(): Promise { + log.info("Orchestration module initialized") +} diff --git a/packages/opencode/src/orchestration/ralph-loop.ts b/packages/opencode/src/orchestration/ralph-loop.ts new file mode 100644 index 00000000000..924a0cb5ec0 --- /dev/null +++ b/packages/opencode/src/orchestration/ralph-loop.ts @@ -0,0 +1,202 @@ +/** + * Ralph Loop - Continuous Execution Until Completion + * + * Implements the Ralph Loop pattern for continuous execution until a completion + * marker is found. Named after the Ralph Wiggum meme: "I'm in danger" -> keeps going. + * + * Features: + * - Completion marker detection (DONE) + * - Iteration tracking and limits + * - State persistence via Storage API (with locking) + * - Event bus integration for status updates + * + * Ported from: scripts/orchestration_state.py + * Uses: opencode's Storage API, Lock system, and Event Bus + */ + +import { Storage } from "../storage/storage" +import { Bus } from "../bus" +import { Instance } from "../project/instance" +import { Log } from "../util/log" +import { OrchestrationEvents } from "./events" + +const log = Log.create({ service: "ralph-loop" }) + +export namespace RalphLoop { + /** + * State shape for Ralph Loop + */ + export interface State { + active: boolean + prompt: string + iteration: number + maxIterations: number + startedAt: string + sessionId: string + completionMarker: string + stoppedAt?: string + stopReason?: string + } + + /** + * Default completion marker that signals the loop should stop + */ + export const DEFAULT_MARKER = "DONE" + + /** + * Storage key for Ralph Loop state + */ + const STORAGE_KEY = ["orchestration", "ralph-loop"] + + /** + * Start a new Ralph Loop + */ + export async function start(options: { + prompt: string + maxIterations?: number + sessionId?: string + }): Promise { + const state: State = { + active: true, + prompt: options.prompt, + iteration: 1, + maxIterations: options.maxIterations ?? 100, + startedAt: new Date().toISOString(), + sessionId: options.sessionId ?? "", + completionMarker: DEFAULT_MARKER, + } + + await Storage.write(STORAGE_KEY, state) + + log.info("Ralph Loop started", { + maxIterations: state.maxIterations, + promptLength: options.prompt.length, + }) + + Bus.publish(OrchestrationEvents.RalphLoopStarted, { + maxIterations: state.maxIterations, + sessionId: state.sessionId, + }) + + return state + } + + /** + * Get current Ralph Loop state if active + */ + export async function get(): Promise { + try { + const state = await Storage.read(STORAGE_KEY) + if (state?.active) { + return state + } + } catch { + // No state exists + } + return undefined + } + + /** + * Check if a response contains the completion marker + */ + export async function checkCompletion(responseText: string): Promise { + const state = await get() + if (!state) { + return true // No loop active, consider complete + } + + const marker = state.completionMarker || DEFAULT_MARKER + return responseText.toLowerCase().includes(marker.toLowerCase()) + } + + /** + * Increment the iteration counter atomically + */ + export async function incrementIteration(): Promise { + try { + const state = await Storage.update(STORAGE_KEY, (draft) => { + if (draft?.active) { + draft.iteration = (draft.iteration || 1) + 1 + } + }) + return state?.active ? state : undefined + } catch { + return undefined + } + } + + /** + * Stop the Ralph Loop + */ + export async function stop(reason: string = "completed"): Promise { + const state = await get() + const iteration = state?.iteration ?? 0 + + await Storage.update(STORAGE_KEY, (draft) => { + if (draft) { + draft.active = false + draft.stoppedAt = new Date().toISOString() + draft.stopReason = reason + } + }) + + log.info("Ralph Loop stopped", { reason, iterationsCompleted: iteration }) + + Bus.publish(OrchestrationEvents.RalphLoopStopped, { + reason, + iterationsCompleted: iteration, + }) + } + + /** + * Check if continuation is needed and return the continuation prompt if so + */ + export async function checkContinuation( + transcriptSummary: string + ): Promise<{ shouldContinue: boolean; prompt?: string }> { + const state = await get() + if (!state) { + return { shouldContinue: false } + } + + // Check if completion marker found + if (await checkCompletion(transcriptSummary)) { + await stop("completed - DONE marker found") + return { shouldContinue: false } + } + + // Check iteration limit + if (state.iteration >= state.maxIterations) { + await stop(`max iterations reached (${state.maxIterations})`) + return { shouldContinue: false } + } + + // Increment and continue + const newState = await incrementIteration() + if (!newState) { + return { shouldContinue: false } + } + + const continuationPrompt = `[RALPH LOOP - Iteration ${newState.iteration}/${newState.maxIterations}] + +The previous iteration did not include ${DEFAULT_MARKER}. +Continue working on the task. When truly complete, include ${DEFAULT_MARKER} in your response. + +Original task: ${state.prompt} + +Continue from where you left off. Do NOT repeat completed work.` + + return { + shouldContinue: true, + prompt: continuationPrompt, + } + } + + /** + * Check if Ralph Loop is currently active + */ + export async function isActive(): Promise { + const state = await get() + return state?.active ?? false + } +} From f2a6f025bee6b9f5101aedc33ac9767791ce2093 Mon Sep 17 00:00:00 2001 From: Bryan Date: Fri, 16 Jan 2026 16:14:39 -0600 Subject: [PATCH 02/15] feat(conatus): Rename fork from opencode to conatus Named after Spinoza's concept of striving to persist in one's being, conatus embodies persistent, joyful work through orchestration and human-AI dialectic integration. Changes: - Renamed package from opencode to conatus - Created new bin/conatus launcher with backward compatibility - Updated global/index.ts to support both conatus and opencode directories - Added CONATUS.md with installation and usage instructions The CLI now runs as `conatus` while maintaining full backward compatibility with existing opencode configurations, environment variables, and directories. Co-Authored-By: Claude Opus 4.5 --- packages/opencode/CONATUS.md | 132 ++++++++++++++++++++++++++ packages/opencode/bin/conatus | 106 +++++++++++++++++++++ packages/opencode/bin/opencode | 84 ---------------- packages/opencode/package.json | 4 +- packages/opencode/src/global/index.ts | 26 ++++- 5 files changed, 261 insertions(+), 91 deletions(-) create mode 100644 packages/opencode/CONATUS.md create mode 100755 packages/opencode/bin/conatus delete mode 100755 packages/opencode/bin/opencode diff --git a/packages/opencode/CONATUS.md b/packages/opencode/CONATUS.md new file mode 100644 index 00000000000..0da6264156b --- /dev/null +++ b/packages/opencode/CONATUS.md @@ -0,0 +1,132 @@ +# Conatus - Sovereign AI Development Environment + +> *Named after Spinoza's conatus: the striving to persist in one's being.* + +Conatus is your sovereign fork of OpenCode, integrating orchestration, the Ralph Loop, and human-AI dialectic systems. + +## Quick Start (Development Mode) + +```bash +# Navigate to the conatus directory +cd /home/bryan/projects/opencode-analysis/packages/opencode + +# Run conatus in development mode (uses bun to run TypeScript directly) +bun run dev + +# Run with a specific model +bun run dev -m anthropic/claude-opus-4-5-20251101 + +# Run headless server mode +bun run dev serve --port 4198 +``` + +## Features + +### Core OpenCode Features +- Multi-model support (Anthropic, OpenAI, Google, local models) +- MCP server integration +- Plugin system +- TUI interface + +### Conatus Extensions +- **Orchestration Module**: Coordinates complex multi-agent tasks +- **Ralph Loop**: Continuous refinement cycles for quality assurance +- **Complexity Detection**: Automatically routes tasks based on complexity +- **Background Agents**: Spawn specialized agents for parallel work +- **Human-in-Loop Dialectic**: Integration with the Sisyphean deliberation system + +## Configuration + +Conatus uses your existing OpenCode configuration at `~/.config/opencode/opencode.json`. + +Your current setup includes: +- Local Ollama models +- RunPod self-hosted models +- Vultr inference proxy +- Velocity router +- Anthropic (Claude) +- Google (Gemini via Antigravity) +- OpenAI (GPT 5.x via OAuth) + +## Environment Variables + +Conatus supports both new and legacy environment variable names: + +| Conatus Variable | Legacy Variable | Purpose | +|-----------------|-----------------|---------| +| `CONATUS_BIN_PATH` | `OPENCODE_BIN_PATH` | Override binary path | +| `CONATUS_CONFIG` | `OPENCODE_CONFIG` | Custom config path | +| `CONATUS_SERVER_PASSWORD` | `OPENCODE_SERVER_PASSWORD` | Server auth | + +## Directory Structure + +``` +~/.config/opencode/ # Configuration (shared with opencode) +~/.cache/opencode/ # Cache directory +~/.local/share/opencode/ # Data directory +~/.local/state/opencode/ # State directory +``` + +## Building for Production + +```bash +# Build the binary +bun run build + +# The binary will be at dist/conatus-linux-x64 (or appropriate platform) +``` + +## Plugins Loaded + +Your configuration loads these plugins: +1. `oh-my-opencode` - Extended functionality +2. `opencode-antigravity-auth` - Antigravity API authentication +3. `./.opencode/plugin/bryan.ts` - Your custom Bryan plugin +4. `opencode-openai-codex-auth` - OpenAI Codex OAuth + +## Integration with Sisyphean Works + +Conatus integrates with your dialectic deliberation system: + +```bash +# Check for pending continuations +python sisyphean-works/bootstrap/tools/continue.py prompt + +# Process dialectic state +python sisyphean-works/bootstrap/tools/dialectic.py check +``` + +## Orchestration Commands + +The orchestration module provides complexity-aware task routing: + +- **Trivial**: Direct execution +- **Simple**: Single-agent tasks +- **Complex**: Multi-step workflows +- **Research**: Deep exploration with multiple agents +- **Ultrawork**: Full Ralph Loop engagement + +## Troubleshooting + +### Plugin Errors +If you see `matcher.hooks is undefined` errors with oh-my-opencode: +```bash +# The plugin at ~/.cache/opencode/node_modules/oh-my-opencode/dist/index.js +# has been patched to handle undefined hooks +``` + +### Missing Dependencies +```bash +cd /home/bryan/projects/opencode-analysis +bun install +``` + +### Port Already in Use +```bash +# Use a different port for the headless server +bun run dev serve --port 4199 +``` + +--- + +*Conatus embodies persistent, joyful work through orchestration and human-AI dialectic integration.* diff --git a/packages/opencode/bin/conatus b/packages/opencode/bin/conatus new file mode 100755 index 00000000000..b4623107447 --- /dev/null +++ b/packages/opencode/bin/conatus @@ -0,0 +1,106 @@ +#!/usr/bin/env node + +/** + * Conatus CLI - Sovereign fork of OpenCode + * + * Named after Spinoza's conatus: the striving to persist in one's being. + * This system embodies persistent, joyful work through orchestration + * and human-AI dialectic integration. + */ + +const childProcess = require("child_process") +const fs = require("fs") +const path = require("path") +const os = require("os") + +function run(target) { + const result = childProcess.spawnSync(target, process.argv.slice(2), { + stdio: "inherit", + }) + if (result.error) { + console.error(result.error.message) + process.exit(1) + } + const code = typeof result.status === "number" ? result.status : 0 + process.exit(code) +} + +// Support both CONATUS_BIN_PATH and OPENCODE_BIN_PATH for compatibility +const envPath = process.env.CONATUS_BIN_PATH || process.env.OPENCODE_BIN_PATH +if (envPath) { + run(envPath) +} + +const scriptPath = fs.realpathSync(__filename) +const scriptDir = path.dirname(scriptPath) + +const platformMap = { + darwin: "darwin", + linux: "linux", + win32: "windows", +} +const archMap = { + x64: "x64", + arm64: "arm64", + arm: "arm", +} + +let platform = platformMap[os.platform()] +if (!platform) { + platform = os.platform() +} +let arch = archMap[os.arch()] +if (!arch) { + arch = os.arch() +} + +// Look for conatus binary first, fall back to opencode for compatibility +const conatusBase = "conatus-" + platform + "-" + arch +const opencodeBase = "opencode-" + platform + "-" + arch +const binary = platform === "windows" ? "conatus.exe" : "conatus" +const opencodeBinary = platform === "windows" ? "opencode.exe" : "opencode" + +function findBinary(startDir) { + let current = startDir + for (;;) { + const modules = path.join(current, "node_modules") + if (fs.existsSync(modules)) { + const entries = fs.readdirSync(modules) + // First try conatus binaries + for (const entry of entries) { + if (entry.startsWith(conatusBase)) { + const candidate = path.join(modules, entry, "bin", binary) + if (fs.existsSync(candidate)) { + return candidate + } + } + } + // Fall back to opencode binaries for compatibility + for (const entry of entries) { + if (entry.startsWith(opencodeBase)) { + const candidate = path.join(modules, entry, "bin", opencodeBinary) + if (fs.existsSync(candidate)) { + return candidate + } + } + } + } + const parent = path.dirname(current) + if (parent === current) { + return + } + current = parent + } +} + +const resolved = findBinary(scriptDir) +if (!resolved) { + console.error( + 'Conatus binary not found. For development, use: bun run dev\n' + + 'For production, install the platform-specific package: "' + + conatusBase + '" or "' + opencodeBase + '"' + ) + process.exit(1) +} + +run(resolved) diff --git a/packages/opencode/bin/opencode b/packages/opencode/bin/opencode deleted file mode 100755 index e35cc00944d..00000000000 --- a/packages/opencode/bin/opencode +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env node - -const childProcess = require("child_process") -const fs = require("fs") -const path = require("path") -const os = require("os") - -function run(target) { - const result = childProcess.spawnSync(target, process.argv.slice(2), { - stdio: "inherit", - }) - if (result.error) { - console.error(result.error.message) - process.exit(1) - } - const code = typeof result.status === "number" ? result.status : 0 - process.exit(code) -} - -const envPath = process.env.OPENCODE_BIN_PATH -if (envPath) { - run(envPath) -} - -const scriptPath = fs.realpathSync(__filename) -const scriptDir = path.dirname(scriptPath) - -const platformMap = { - darwin: "darwin", - linux: "linux", - win32: "windows", -} -const archMap = { - x64: "x64", - arm64: "arm64", - arm: "arm", -} - -let platform = platformMap[os.platform()] -if (!platform) { - platform = os.platform() -} -let arch = archMap[os.arch()] -if (!arch) { - arch = os.arch() -} -const base = "opencode-" + platform + "-" + arch -const binary = platform === "windows" ? "opencode.exe" : "opencode" - -function findBinary(startDir) { - let current = startDir - for (;;) { - const modules = path.join(current, "node_modules") - if (fs.existsSync(modules)) { - const entries = fs.readdirSync(modules) - for (const entry of entries) { - if (!entry.startsWith(base)) { - continue - } - const candidate = path.join(modules, entry, "bin", binary) - if (fs.existsSync(candidate)) { - return candidate - } - } - } - const parent = path.dirname(current) - if (parent === current) { - return - } - current = parent - } -} - -const resolved = findBinary(scriptDir) -if (!resolved) { - console.error( - 'It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the "' + - base + - '" package', - ) - process.exit(1) -} - -run(resolved) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c347b63a31b..c65586a861f 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "version": "1.1.24", - "name": "opencode", + "name": "conatus", "type": "module", "license": "MIT", "private": true, @@ -18,7 +18,7 @@ "deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'" }, "bin": { - "opencode": "./bin/opencode" + "conatus": "./bin/conatus" }, "exports": { "./*": "./src/*.ts" diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index d3011b41506..ea26358e9f8 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -1,14 +1,30 @@ import fs from "fs/promises" +import { existsSync } from "fs" import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" import path from "path" import os from "os" -const app = "opencode" +// Conatus: Named after Spinoza's concept of striving to persist in one's being +// Backward compatible with opencode directories +const APP_NAME = "conatus" +const LEGACY_APP_NAME = "opencode" -const data = path.join(xdgData!, app) -const cache = path.join(xdgCache!, app) -const config = path.join(xdgConfig!, app) -const state = path.join(xdgState!, app) +// Check if legacy directories exist and prefer them for backward compatibility +// New installations will use "conatus" directories +function resolveAppDir(xdgBase: string | undefined): string { + const legacyPath = path.join(xdgBase!, LEGACY_APP_NAME) + const newPath = path.join(xdgBase!, APP_NAME) + // Prefer existing legacy dirs for backward compatibility + if (existsSync(legacyPath) && !existsSync(newPath)) { + return legacyPath + } + return existsSync(newPath) ? newPath : legacyPath // Default to legacy for now +} + +const data = resolveAppDir(xdgData) +const cache = resolveAppDir(xdgCache) +const config = resolveAppDir(xdgConfig) +const state = resolveAppDir(xdgState) export namespace Global { export const Path = { From 794837969324e32de4bdc2a2785aaa885c352723 Mon Sep 17 00:00:00 2001 From: Bryan Date: Fri, 16 Jan 2026 16:38:46 -0600 Subject: [PATCH 03/15] feat(conatus): Restore Claude OAuth support for EXACT auth parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mined commit history and found removed auth/anthropic.ts that provides Claude OAuth via claude.ai - the SAME authentication flow Claude Code uses. Changes: - Restored src/auth/anthropic.ts with dual-mode OAuth (claude.ai + console) - Updated provider.ts to use OAuth when credentials available - Added yaml dependency for dialectic module - Fixed workspace reference (opencode → conatus in packages/web) - Added HEADLESS-CONNECTION.md with remote access instructions Now conatus uses IDENTICAL authentication to Claude Code: - Same OAuth client_id: 9d1c250a-e61b-44d9-88ed-5944d1962f5e - Same token endpoint: console.anthropic.com/v1/oauth/token - Same scopes: org:create_api_key user:profile user:inference - Same billing: Your Claude subscription THRUST//VECTOR: NO BRAKES. Auth parity achieved. Co-Authored-By: Claude Opus 4.5 --- bun.lock | 27 ++++--- packages/opencode/HEADLESS-CONNECTION.md | 87 ++++++++++++++++++++++ packages/opencode/package.json | 1 + packages/opencode/src/auth/anthropic.ts | 84 +++++++++++++++++++++ packages/opencode/src/provider/provider.ts | 28 ++++++- packages/web/package.json | 2 +- 6 files changed, 215 insertions(+), 14 deletions(-) create mode 100644 packages/opencode/HEADLESS-CONNECTION.md create mode 100644 packages/opencode/src/auth/anthropic.ts diff --git a/bun.lock b/bun.lock index 9bdda38042c..c7720437d05 100644 --- a/bun.lock +++ b/bun.lock @@ -252,10 +252,10 @@ }, }, "packages/opencode": { - "name": "opencode", + "name": "conatus", "version": "1.1.24", "bin": { - "opencode": "./bin/opencode", + "conatus": "./bin/conatus", }, "dependencies": { "@actions/core": "1.11.1", @@ -327,6 +327,7 @@ "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "xdg-basedir": "5.1.0", + "yaml": "2.7.0", "yargs": "18.0.0", "zod": "catalog:", "zod-to-json-schema": "3.24.5", @@ -477,7 +478,7 @@ }, "devDependencies": { "@types/node": "catalog:", - "opencode": "workspace:*", + "conatus": "workspace:*", "typescript": "catalog:", }, }, @@ -2182,6 +2183,8 @@ "compress-commons": ["compress-commons@6.0.2", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^6.0.0", "is-stream": "^2.0.1", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg=="], + "conatus": ["conatus@workspace:packages/opencode"], + "condense-newlines": ["condense-newlines@0.2.1", "", { "dependencies": { "extend-shallow": "^2.0.1", "is-whitespace": "^0.3.0", "kind-of": "^3.0.2" } }, "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg=="], "confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], @@ -3186,8 +3189,6 @@ "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], - "opencode": ["opencode@workspace:packages/opencode"], - "opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="], "openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="], @@ -3922,7 +3923,7 @@ "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - "yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="], + "yaml": ["yaml@2.7.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA=="], "yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], @@ -4310,6 +4311,12 @@ "compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "conatus/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="], + + "conatus/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="], + + "conatus/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="], + "condense-newlines/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -4394,12 +4401,6 @@ "nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], - "opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="], - - "opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="], - - "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="], - "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="], "opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="], @@ -4428,6 +4429,8 @@ "postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + "postcss-load-config/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="], + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], diff --git a/packages/opencode/HEADLESS-CONNECTION.md b/packages/opencode/HEADLESS-CONNECTION.md new file mode 100644 index 00000000000..0639c2705ab --- /dev/null +++ b/packages/opencode/HEADLESS-CONNECTION.md @@ -0,0 +1,87 @@ +# Conatus Headless Server Connection Guide + +## Quick Reference + +### On the Server (inside tmux via mosh/tailscale) + +```bash +# Start tmux session +tmux new -s conatus + +# Set password (REQUIRED for security) +export OPENCODE_SERVER_PASSWORD="your-secure-password" + +# Start headless server +cd /home/bryan/projects/opencode-analysis/packages/opencode +bun run dev serve --port 4198 + +# Detach: Ctrl+B, D +# Reattach later: tmux attach -t conatus +``` + +### From Any Client (local or remote) + +```bash +# Set same password +export OPENCODE_SERVER_PASSWORD="your-secure-password" + +# Attach to remote server via Tailscale +cd /home/bryan/projects/opencode-analysis/packages/opencode +bun run dev attach http://:4198 + +# Or use web interface +# Navigate to: http://:4198 +``` + +## Authentication Comparison + +| Aspect | Claude Code | Conatus | +|--------|-------------|---------| +| **Model** | claude-opus-4-5-20251101 | claude-opus-4-5-20251101 | +| **Auth** | Claude Code subscription | Anthropic OAuth (your account) | +| **Credentials** | Automatic | ~/.local/share/opencode/auth.json | +| **Billing** | Claude Code sub | Your Anthropic account | + +## Your Existing Credentials + +Your `~/.local/share/opencode/auth.json` has valid OAuth for: +- Anthropic (expires Jan 2026) +- Google +- GitHub Copilot +- Groq, Cerebras, OpenRouter, DeepSeek, Venice +- Vultr, RunPod + +All providers work immediately with conatus. + +## Full Workflow Example + +```bash +# 1. From local machine, connect to server via tailscale +mosh bryan@ + +# 2. Start conatus in tmux +tmux new -s conatus +export OPENCODE_SERVER_PASSWORD="secure-pass" +cd ~/projects/opencode-analysis/packages/opencode +bun run dev serve --port 4198 +# Ctrl+B, D to detach +# exit mosh + +# 3. From local machine, attach to the running server +export OPENCODE_SERVER_PASSWORD="secure-pass" +cd ~/projects/opencode-analysis/packages/opencode +bun run dev attach http://:4198 +``` + +## Using Specific Models + +```bash +# Use your Anthropic OAuth +bun run dev -m anthropic/claude-opus-4-5-20251101 + +# Use Vultr inference +bun run dev -m vultr-inference/kimi-k2-instruct + +# Use velocity router +bun run dev -m velocity/fast +``` diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c65586a861f..f7a8a99035d 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -116,6 +116,7 @@ "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "xdg-basedir": "5.1.0", + "yaml": "2.7.0", "yargs": "18.0.0", "zod": "catalog:", "zod-to-json-schema": "3.24.5" diff --git a/packages/opencode/src/auth/anthropic.ts b/packages/opencode/src/auth/anthropic.ts new file mode 100644 index 00000000000..d3228cb88ba --- /dev/null +++ b/packages/opencode/src/auth/anthropic.ts @@ -0,0 +1,84 @@ +import { generatePKCE } from "@openauthjs/openauth/pkce" +import { Auth } from "./index" + +export namespace AuthAnthropic { + const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + + export async function authorize(mode: "max" | "console") { + const pkce = await generatePKCE() + + const url = new URL( + `https://${mode === "console" ? "console.anthropic.com" : "claude.ai"}/oauth/authorize`, + import.meta.url, + ) + url.searchParams.set("code", "true") + url.searchParams.set("client_id", CLIENT_ID) + url.searchParams.set("response_type", "code") + url.searchParams.set("redirect_uri", "https://console.anthropic.com/oauth/code/callback") + url.searchParams.set("scope", "org:create_api_key user:profile user:inference") + url.searchParams.set("code_challenge", pkce.challenge) + url.searchParams.set("code_challenge_method", "S256") + url.searchParams.set("state", pkce.verifier) + return { + url: url.toString(), + verifier: pkce.verifier, + } + } + + export async function exchange(code: string, verifier: string) { + const splits = code.split("#") + const result = await fetch("https://console.anthropic.com/v1/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code: splits[0], + state: splits[1], + grant_type: "authorization_code", + client_id: CLIENT_ID, + redirect_uri: "https://console.anthropic.com/oauth/code/callback", + code_verifier: verifier, + }), + }) + if (!result.ok) throw new ExchangeFailed() + const json = await result.json() + return { + refresh: json.refresh_token as string, + access: json.access_token as string, + expires: Date.now() + json.expires_in * 1000, + } + } + + export async function access() { + const info = await Auth.get("anthropic") + if (!info || info.type !== "oauth") return + if (info.access && info.expires > Date.now()) return info.access + const response = await fetch("https://console.anthropic.com/v1/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: info.refresh, + client_id: CLIENT_ID, + }), + }) + if (!response.ok) return + const json = await response.json() + await Auth.set("anthropic", { + type: "oauth", + refresh: json.refresh_token as string, + access: json.access_token as string, + expires: Date.now() + json.expires_in * 1000, + }) + return json.access_token as string + } + + export class ExchangeFailed extends Error { + constructor() { + super("Exchange failed") + } + } +} diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 9e2dd0ba0b5..0eb874f479c 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -9,6 +9,7 @@ import { Plugin } from "../plugin" import { ModelsDev } from "./models" import { NamedError } from "@opencode-ai/util/error" import { Auth } from "../auth" +import { AuthAnthropic } from "../auth/anthropic" import { Env } from "../env" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" @@ -74,7 +75,32 @@ export namespace Provider { }> const CUSTOM_LOADERS: Record = { - async anthropic() { + async anthropic(provider) { + // Try OAuth first (same auth as Claude Code / claude.ai) + const access = await AuthAnthropic.access() + if (access) { + // OAuth available - use it (free via Claude subscription) + for (const model of Object.values(provider.models)) { + model.cost = { input: 0, output: 0, cache: { read: 0, write: 0 } } + } + return { + autoload: true, + options: { + apiKey: "", // OAuth doesn't need API key + async fetch(input: any, init: any) { + const accessToken = await AuthAnthropic.access() + const headers = { + ...init.headers, + authorization: `Bearer ${accessToken}`, + "anthropic-beta": "oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", + } + delete headers["x-api-key"] + return fetch(input, { ...init, headers }) + }, + }, + } + } + // Fall back to API key auth return { autoload: false, options: { diff --git a/packages/web/package.json b/packages/web/package.json index ebbc7961f5a..7fb8dbefbf5 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -34,7 +34,7 @@ "toolbeam-docs-theme": "0.4.8" }, "devDependencies": { - "opencode": "workspace:*", + "conatus": "workspace:*", "@types/node": "catalog:", "typescript": "catalog:" } From c62658255fccb716dca25ae0877ad0a4054ca9c1 Mon Sep 17 00:00:00 2001 From: Bryan Date: Fri, 16 Jan 2026 17:10:26 -0600 Subject: [PATCH 04/15] feat(conatus): Complete Anthropic OAuth integration via plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add opencode-anthropic-auth plugin to config for Claude Code auth parity - Remove conflicting custom anthropic loader from provider.ts (plugin handles it) - Add conatus-dev script for system-wide development installation - Keep auth/anthropic.ts for reference (credentials bridging code) The opencode-anthropic-auth plugin handles: - System prompt sanitization (OpenCode → Claude Code) - Tool name prefixing with mcp_ - Beta header management including claude-code-20250219 - Stream response transformation Tested and working with anthropic/claude-opus-4-5-20251101 using Claude Code subscription OAuth credentials. Co-Authored-By: Claude Opus 4.5 --- packages/opencode/bin/conatus-dev | 10 ++++ packages/opencode/src/auth/anthropic.ts | 56 ++++++++++++++++++++++ packages/opencode/src/provider/provider.ts | 40 ++-------------- 3 files changed, 69 insertions(+), 37 deletions(-) create mode 100755 packages/opencode/bin/conatus-dev diff --git a/packages/opencode/bin/conatus-dev b/packages/opencode/bin/conatus-dev new file mode 100755 index 00000000000..c3dae3700a6 --- /dev/null +++ b/packages/opencode/bin/conatus-dev @@ -0,0 +1,10 @@ +#!/bin/bash +# Conatus Development Launcher +# Use this for system-wide development access +# +# Named after Spinoza's conatus: the striving to persist in one's being. + +CONATUS_DIR="/home/bryan/projects/opencode-analysis/packages/opencode" + +cd "$CONATUS_DIR" +exec bun run --conditions=browser ./src/index.ts "$@" diff --git a/packages/opencode/src/auth/anthropic.ts b/packages/opencode/src/auth/anthropic.ts index d3228cb88ba..98c68552449 100644 --- a/packages/opencode/src/auth/anthropic.ts +++ b/packages/opencode/src/auth/anthropic.ts @@ -1,9 +1,35 @@ import { generatePKCE } from "@openauthjs/openauth/pkce" import { Auth } from "./index" +import path from "path" +import os from "os" export namespace AuthAnthropic { const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + // Bridge to Claude Code credentials for EXACT auth parity + // Claude Code stores OAuth tokens with user:sessions:claude_code scope + // which is required for API access via subscription + interface ClaudeCodeCredentials { + claudeAiOauth?: { + accessToken: string + refreshToken: string + expiresAt: number + scopes: string[] + subscriptionType?: string + } + } + + async function getClaudeCodeCredentials(): Promise { + try { + const credPath = path.join(os.homedir(), ".claude", ".credentials.json") + const file = Bun.file(credPath) + if (!(await file.exists())) return null + return await file.json() + } catch { + return null + } + } + export async function authorize(mode: "max" | "console") { const pkce = await generatePKCE() @@ -51,6 +77,36 @@ export namespace AuthAnthropic { } export async function access() { + // PRIORITY 1: Bridge to Claude Code credentials (exact same auth) + // This uses the user:sessions:claude_code scope that Claude Code has + const claudeCodeCreds = await getClaudeCodeCredentials() + if (claudeCodeCreds?.claudeAiOauth) { + const oauth = claudeCodeCreds.claudeAiOauth + // Check if token is still valid (with 60s buffer) + if (oauth.accessToken && oauth.expiresAt > Date.now() + 60000) { + return oauth.accessToken + } + // Token expired - try to refresh using Claude Code's refresh token + if (oauth.refreshToken) { + try { + const response = await fetch("https://claude.ai/api/auth/oauth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: oauth.refreshToken }), + }) + if (response.ok) { + const json = await response.json() + // Note: We can't write back to Claude Code's credentials file + // but we can use the refreshed token for this session + return json.access_token as string + } + } catch { + // Fall through to OpenCode auth + } + } + } + + // PRIORITY 2: OpenCode's own OAuth tokens const info = await Auth.get("anthropic") if (!info || info.type !== "oauth") return if (info.access && info.expires > Date.now()) return info.access diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 0eb874f479c..d9e16c96b58 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -9,7 +9,7 @@ import { Plugin } from "../plugin" import { ModelsDev } from "./models" import { NamedError } from "@opencode-ai/util/error" import { Auth } from "../auth" -import { AuthAnthropic } from "../auth/anthropic" +// AuthAnthropic is handled by opencode-anthropic-auth plugin import { Env } from "../env" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" @@ -75,42 +75,8 @@ export namespace Provider { }> const CUSTOM_LOADERS: Record = { - async anthropic(provider) { - // Try OAuth first (same auth as Claude Code / claude.ai) - const access = await AuthAnthropic.access() - if (access) { - // OAuth available - use it (free via Claude subscription) - for (const model of Object.values(provider.models)) { - model.cost = { input: 0, output: 0, cache: { read: 0, write: 0 } } - } - return { - autoload: true, - options: { - apiKey: "", // OAuth doesn't need API key - async fetch(input: any, init: any) { - const accessToken = await AuthAnthropic.access() - const headers = { - ...init.headers, - authorization: `Bearer ${accessToken}`, - "anthropic-beta": "oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", - } - delete headers["x-api-key"] - return fetch(input, { ...init, headers }) - }, - }, - } - } - // Fall back to API key auth - return { - autoload: false, - options: { - headers: { - "anthropic-beta": - "claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", - }, - }, - } - }, + // Anthropic OAuth is handled by opencode-anthropic-auth plugin + // which transforms requests to work with Claude Code credentials async opencode(input) { const hasKey = await (async () => { const env = Env.all() From a1314d2c5d9a0e3e8180ff6f7f405a2a5e7821b0 Mon Sep 17 00:00:00 2001 From: Bryan Date: Fri, 16 Jan 2026 17:31:03 -0600 Subject: [PATCH 05/15] fix(conatus): Default to claude-opus-4-5-20251101 in launcher The launcher now injects -m anthropic/claude-opus-4-5-20251101 when no model is explicitly specified. This works around a hang in the default model resolution path (possibly due to sleeping RunPod pod). Co-Authored-By: Claude Opus 4.5 --- packages/opencode/bin/conatus-dev | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/opencode/bin/conatus-dev b/packages/opencode/bin/conatus-dev index c3dae3700a6..0518b569531 100755 --- a/packages/opencode/bin/conatus-dev +++ b/packages/opencode/bin/conatus-dev @@ -5,6 +5,13 @@ # Named after Spinoza's conatus: the striving to persist in one's being. CONATUS_DIR="/home/bryan/projects/opencode-analysis/packages/opencode" +DEFAULT_MODEL="anthropic/claude-opus-4-5-20251101" cd "$CONATUS_DIR" -exec bun run --conditions=browser ./src/index.ts "$@" + +# If no -m/--model flag provided, inject the default +if [[ ! " $* " =~ " -m " ]] && [[ ! " $* " =~ " --model " ]]; then + exec bun run --conditions=browser ./src/index.ts -m "$DEFAULT_MODEL" "$@" +else + exec bun run --conditions=browser ./src/index.ts "$@" +fi From e4d74e97c8b3121766e93fc8aecc6234a76f0f52 Mon Sep 17 00:00:00 2001 From: Bryan Date: Sat, 17 Jan 2026 17:22:02 -0600 Subject: [PATCH 06/15] feat(ui): Add lab session system and improve active agents display - Add DialogLabList component for Ctrl+L lab switching - Support the four canonical labs: Bootstrap, Study, Teach, Govern - Each lab has persistent sessions - switch to existing or create new - Add lab_list keybind (Ctrl+L) in config - Register lab.list command in app.tsx Active Agents improvements: - Add spinner animations for working/retrying agents - Hover highlight on agent rows for better click targets - Click to switch sessions works perfectly - Display lab badge in sidebar header when in a lab session - Collapsible agents list when >2 agents --- packages/opencode/src/cli/cmd/tui/app.tsx | 11 + .../cli/cmd/tui/component/dialog-lab-list.tsx | 208 ++++++++++++++++++ .../cli/cmd/tui/routes/session/sidebar.tsx | 118 +++++++++- packages/opencode/src/config/config.ts | 1 + 4 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 1fea3f4b305..873690ed7a3 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -18,6 +18,7 @@ import { DialogHelp } from "./ui/dialog-help" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" +import { DialogLabList } from "@tui/component/dialog-lab-list" import { KeybindProvider } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" @@ -297,6 +298,16 @@ function App() { dialog.replace(() => ) }, }, + { + title: "Switch lab", + value: "lab.list", + keybind: "lab_list", + category: "Session", + suggested: true, + onSelect: () => { + dialog.replace(() => ) + }, + }, { title: "New session", suggested: route.data.type === "session", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx new file mode 100644 index 00000000000..dc7a338064f --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx @@ -0,0 +1,208 @@ +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { useRoute } from "@tui/context/route" +import { useSync } from "@tui/context/sync" +import { createMemo, createSignal, onMount, Show } from "solid-js" +import { useTheme } from "../context/theme" +import { useSDK } from "../context/sdk" +import { useKV } from "../context/kv" +import { RGBA } from "@opentui/core" +import "opentui-spinner/solid" + +/** + * Lab configuration - the four canonical labs in Conatus + */ +export interface Lab { + id: string + name: string + icon: string + description: string + color: "primary" | "secondary" | "accent" | "success" | "warning" | "error" +} + +export const LABS: Lab[] = [ + { + id: "bootstrap", + name: "Bootstrap", + icon: "\u26a1", + description: "Core infrastructure and initialization", + color: "warning", + }, + { + id: "the-study-lab", + name: "The Study", + icon: "\ud83d\udcda", + description: "Research, analysis, and knowledge synthesis", + color: "primary", + }, + { + id: "the-teach-lab", + name: "The Teach", + icon: "\ud83c\udf93", + description: "Documentation, tutorials, and knowledge transfer", + color: "success", + }, + { + id: "the-govern-lab", + name: "The Govern", + icon: "\u2696\ufe0f", + description: "Policy, governance, and system oversight", + color: "accent", + }, +] + +/** + * Find an existing session for a lab by matching title + */ +function findLabSession(sessions: any[], lab: Lab) { + return sessions.find((s) => { + if (s.parentID) return false // Skip child sessions + const title = s.title?.toLowerCase() || "" + return title.includes(lab.id.toLowerCase()) || title.includes(lab.name.toLowerCase()) + }) +} + +/** + * Dialog for selecting and switching between labs + * + * Each lab has a persistent session - selecting a lab either: + * 1. Navigates to the existing session for that lab + * 2. Creates a new session with the lab's title + */ +export function DialogLabList() { + const dialog = useDialog() + const route = useRoute() + const sync = useSync() + const { theme } = useTheme() + const sdk = useSDK() + const kv = useKV() + + // Animation frames for working indicator + const workingFrames = ["\u25d0", "\u25d3", "\u25d1", "\u25d2"] + const [switching, setSwitching] = createSignal(null) + + // Get current session to mark as active + const currentSessionID = createMemo(() => + route.data.type === "session" ? route.data.sessionID : undefined + ) + + // Find which lab the current session belongs to (if any) + const currentLab = createMemo(() => { + const sessionID = currentSessionID() + if (!sessionID) return undefined + const session = sync.session.get(sessionID) + if (!session) return undefined + + for (const lab of LABS) { + const title = session.title?.toLowerCase() || "" + if (title.includes(lab.id.toLowerCase()) || title.includes(lab.name.toLowerCase())) { + return lab.id + } + } + return undefined + }) + + // Build options for each lab + const options = createMemo(() => { + const activeLabs: (DialogSelectOption & { _session?: any; _lab: Lab })[] = [] + const availableLabs: (DialogSelectOption & { _session?: any; _lab: Lab })[] = [] + + for (const lab of LABS) { + const existingSession = findLabSession(sync.data.session, lab) + const isActive = currentLab() === lab.id + const isBusy = existingSession && sync.data.session_status?.[existingSession.id]?.type === "busy" + const isRetrying = existingSession && sync.data.session_status?.[existingSession.id]?.type === "retry" + + const option = { + title: `${lab.icon} ${lab.name}`, + value: lab.id, + description: lab.description, + category: existingSession ? "Active Labs" : "Available Labs", + footer: existingSession ? ( + ready}> + [...]} + > + + + + ) : ( + new + ), + gutter: isActive ? ( + \u25cf + ) : switching() === lab.id ? ( + + ) : existingSession ? ( + \u25cb + ) : undefined, + _session: existingSession, + _lab: lab, + } as DialogSelectOption & { _session?: any; _lab: Lab } + + if (existingSession) { + activeLabs.push(option) + } else { + availableLabs.push(option) + } + } + + // Show active labs first, then available labs + return [...activeLabs, ...availableLabs] + }) + + onMount(() => { + dialog.setSize("medium") + }) + + const handleSelect = async (option: DialogSelectOption & { _session?: any; _lab: Lab }) => { + const lab = option._lab + const existingSession = option._session + + if (existingSession) { + // Navigate to existing session + route.navigate({ + type: "session", + sessionID: existingSession.id, + }) + dialog.clear() + } else { + // Create new session for this lab + setSwitching(lab.id) + try { + const result = await sdk.client.session.create({ + title: `${lab.icon} ${lab.name}`, + }) + if (result.data) { + route.navigate({ + type: "session", + sessionID: result.data.id, + }) + } + dialog.clear() + } catch (error) { + console.error("Failed to create lab session:", error) + setSwitching(null) + } + } + } + + return ( + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index ebc7514d723..b9265f5a7d7 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -1,7 +1,8 @@ import { useSync } from "@tui/context/sync" -import { createMemo, For, Show, Switch, Match } from "solid-js" +import { createMemo, createSignal, For, Show, Switch, Match, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "../../context/theme" +import { useRoute } from "../../context/route" import { Locale } from "@/util/locale" import path from "path" import type { AssistantMessage } from "@opencode-ai/sdk/v2" @@ -11,20 +12,61 @@ import { useKeybind } from "../../context/keybind" import { useDirectory } from "../../context/directory" import { useKV } from "../../context/kv" import { TodoItem } from "../../component/todo-item" +import "opentui-spinner/solid" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() const { theme } = useTheme() + const route = useRoute() const session = createMemo(() => sync.session.get(props.sessionID)!) const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? []) const todo = createMemo(() => sync.data.todo[props.sessionID] ?? []) const messages = createMemo(() => sync.data.message[props.sessionID] ?? []) + // Active Agents: child sessions spawned from this session + const activeAgents = createMemo(() => { + const parentID = props.sessionID + return sync.data.session + .filter((s) => s.parentID === parentID) + .map((s) => ({ + ...s, + status: sync.data.session_status[s.id], + })) + }) + + // Lab detection: derive lab from session title or directory + const labInfo = createMemo(() => { + const s = session() + if (!s) return null + const title = s.title?.toLowerCase() || "" + const dir = s.directory?.toLowerCase() || "" + + // The four official labs only + const labs: { pattern: RegExp; name: string; icon: string }[] = [ + { pattern: /bootstrap/, name: "Bootstrap Lab", icon: "⚡" }, + { pattern: /study[-_]?lab|the[-_]?study/, name: "Study Lab", icon: "📚" }, + { pattern: /teach[-_]?lab|the[-_]?teach/, name: "Teach Lab", icon: "🎓" }, + { pattern: /govern[-_]?lab|the[-_]?govern/, name: "Govern Lab", icon: "⚖️" }, + ] + + for (const lab of labs) { + if (lab.pattern.test(title) || lab.pattern.test(dir)) { + return { name: lab.name, icon: lab.icon } + } + } + return null + }) + + // Animation frames for smooth status indicators + const spinnerFrames = ["◐", "◓", "◑", "◒"] + const animationsEnabled = createMemo(() => kv.get("animations_enabled", true)) + const [expanded, setExpanded] = createStore({ mcp: true, diff: true, todo: true, lsp: true, + agents: true, }) // Sort MCP servers alphabetically for consistent display order @@ -85,6 +127,11 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {session().title} + + + {labInfo()!.icon} {labInfo()!.name} + + {session().share!.url} @@ -294,6 +341,75 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { + 0}> + + activeAgents().length > 2 && setExpanded("agents", !expanded.agents)} + > + 2}> + {expanded.agents ? "▼" : "▶"} + + + Active Agents + + + {" "} + ({activeAgents().filter((a) => a.status?.type === "busy").length} working) + + + + + + + {(agent) => { + const isBusy = agent.status?.type === "busy" + const isRetrying = agent.status?.type === "retry" + const statusColor = isBusy ? theme.success : isRetrying ? theme.warning : theme.textMuted + const [hover, setHover] = createSignal(false) + + return ( + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseDown={() => route.navigate({ type: "session", sessionID: agent.id })} + > + + {isBusy ? "●" : isRetrying ? "○" : "○"} + + } + > + + + + {agent.title}{" "} + + + working + retrying + + + + + ) + }} + + + + {directory().split("/").slice(0, -1).join("/")}/ {directory().split("/").at(-1)} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 355b3ba0017..e199b220591 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -644,6 +644,7 @@ export namespace Config { session_export: z.string().optional().default("x").describe("Export session to editor"), session_new: z.string().optional().default("n").describe("Create a new session"), session_list: z.string().optional().default("l").describe("List all sessions"), + lab_list: z.string().optional().default("ctrl+l").describe("List and switch labs"), session_timeline: z.string().optional().default("g").describe("Show session timeline"), session_fork: z.string().optional().default("none").describe("Fork session from message"), session_rename: z.string().optional().default("ctrl+r").describe("Rename session"), From 7ce5d4abbde9fce9c071f1ee3babfdb85bbf800f Mon Sep 17 00:00:00 2001 From: Bryan Date: Sat, 17 Jan 2026 17:24:43 -0600 Subject: [PATCH 07/15] feat(ui): Add lab icon badge to session header Show lab emoji icon in header when session belongs to a lab, providing visual indicator even when sidebar is hidden --- .../src/cli/cmd/tui/routes/session/header.tsx | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index afcb2c6118d..a6859a15583 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -9,12 +9,41 @@ import { useCommandDialog } from "@tui/component/dialog-command" import { useKeybind } from "../../context/keybind" import { Installation } from "@/installation" +// Lab detection logic +const LABS = [ + { pattern: /bootstrap/i, name: "Bootstrap", icon: "\u26a1" }, + { pattern: /study[-_]?lab|the[-_]?study/i, name: "Study", icon: "\ud83d\udcda" }, + { pattern: /teach[-_]?lab|the[-_]?teach/i, name: "Teach", icon: "\ud83c\udf93" }, + { pattern: /govern[-_]?lab|the[-_]?govern/i, name: "Govern", icon: "\u2696\ufe0f" }, +] + +function detectLab(session: Session) { + const title = session.title?.toLowerCase() || "" + const dir = session.directory?.toLowerCase() || "" + + for (const lab of LABS) { + if (lab.pattern.test(title) || lab.pattern.test(dir)) { + return lab + } + } + return null +} + const Title = (props: { session: Accessor }) => { const { theme } = useTheme() + const lab = createMemo(() => detectLab(props.session())) + return ( - - # {props.session().title} - + + + + {lab()!.icon} + + + + # {props.session().title} + + ) } From 3cb5ca7e3b4c1a9330643ebbab182464035995a0 Mon Sep 17 00:00:00 2001 From: Bryan Date: Sat, 17 Jan 2026 17:26:30 -0600 Subject: [PATCH 08/15] feat(ui): Add tip about lab session switching Educate users about Ctrl+L shortcut for switching between labs --- packages/opencode/src/cli/cmd/tui/component/tips.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/cli/cmd/tui/component/tips.tsx b/packages/opencode/src/cli/cmd/tui/component/tips.tsx index fe2e7ca2169..422bbd56523 100644 --- a/packages/opencode/src/cli/cmd/tui/component/tips.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/tips.tsx @@ -150,4 +150,5 @@ const TIPS = [ "Use {highlight}/details{/highlight} to toggle tool execution details visibility", "Use {highlight}/rename{/highlight} to rename the current session", "Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell", + "Press {highlight}Ctrl+L{/highlight} to switch between lab sessions (Bootstrap, Study, Teach, Govern)", ] From ae004417af0c381deb3093380617064ad6db8586 Mon Sep 17 00:00:00 2001 From: Bryan Date: Sat, 17 Jan 2026 17:29:16 -0600 Subject: [PATCH 09/15] feat(ui): Add Labs shortcut hint to home screen footer Shows Labs indicator with Ctrl+L keybind for quick discovery --- packages/opencode/src/cli/cmd/tui/routes/home.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 59923c69d94..a8ed2595dda 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -131,7 +131,12 @@ export function Home() { - + + + Labs{" "} + {keybind.print("lab_list")} + + · {Installation.VERSION} From f4447dff2438a7c8051ae74052b8e9f408e5f14f Mon Sep 17 00:00:00 2001 From: Bryan Date: Sat, 17 Jan 2026 17:40:07 -0600 Subject: [PATCH 10/15] feat(ui): Add lab badges to session list and footer, add toast feedback - Show lab icons in session list next to timestamps - Display current lab name and icon in session footer - Add Labs shortcut hint in session footer - Show toast notifications when switching labs or creating new lab sessions - Color-code spinners based on lab theme color --- .../cli/cmd/tui/component/dialog-lab-list.tsx | 18 ++++++++++- .../cmd/tui/component/dialog-session-list.tsx | 29 +++++++++++++++-- .../src/cli/cmd/tui/routes/session/footer.tsx | 31 +++++++++++++++++-- 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx index dc7a338064f..026efe9bcc4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx @@ -6,7 +6,7 @@ import { createMemo, createSignal, onMount, Show } from "solid-js" import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" import { useKV } from "../context/kv" -import { RGBA } from "@opentui/core" +import { useToast } from "../ui/toast" import "opentui-spinner/solid" /** @@ -76,6 +76,7 @@ export function DialogLabList() { const { theme } = useTheme() const sdk = useSDK() const kv = useKV() + const toast = useToast() // Animation frames for working indicator const workingFrames = ["\u25d0", "\u25d3", "\u25d1", "\u25d2"] @@ -175,6 +176,11 @@ export function DialogLabList() { sessionID: existingSession.id, }) dialog.clear() + toast.show({ + message: `Switched to ${lab.name}`, + variant: "success", + duration: 2000, + }) } else { // Create new session for this lab setSwitching(lab.id) @@ -187,11 +193,21 @@ export function DialogLabList() { type: "session", sessionID: result.data.id, }) + toast.show({ + message: `Created ${lab.name} session`, + variant: "success", + duration: 2000, + }) } dialog.clear() } catch (error) { console.error("Failed to create lab session:", error) setSwitching(null) + toast.show({ + message: `Failed to create ${lab.name} session`, + variant: "error", + duration: 3000, + }) } } } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 85c174c1dcb..8b151a52269 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -10,6 +10,7 @@ import { useSDK } from "../context/sdk" import { DialogSessionRename } from "./dialog-session-rename" import { useKV } from "../context/kv" import { createDebouncedSignal } from "../util/signal" +import { LABS } from "./dialog-lab-list" import "opentui-spinner/solid" export function DialogSessionList() { @@ -36,6 +37,14 @@ export function DialogSessionList() { const sessions = createMemo(() => searchResults() ?? sync.data.session) + // Helper to detect if a session belongs to a lab + const getSessionLab = (session: { title?: string }) => { + const title = session.title?.toLowerCase() || "" + return LABS.find( + (lab) => title.includes(lab.id.toLowerCase()) || title.includes(lab.name.toLowerCase()) + ) + } + const options = createMemo(() => { const today = new Date().toDateString() return sessions() @@ -50,15 +59,29 @@ export function DialogSessionList() { const isDeleting = toDelete() === x.id const status = sync.data.session_status?.[x.id] const isWorking = status?.type === "busy" + const lab = getSessionLab(x) + + // Build title with lab icon if applicable + const displayTitle = isDeleting + ? `Press ${keybind.print("session_delete")} again to confirm` + : x.title + return { - title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, + title: displayTitle, bg: isDeleting ? theme.error : undefined, value: x.id, category, - footer: Locale.time(x.time.updated), + footer: ( + + + {lab!.icon} + + {Locale.time(x.time.updated)} + + ), gutter: isWorking ? ( [⋯]}> - + ) : undefined, } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index 8ace2fff372..b1a58fd5a97 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -5,11 +5,14 @@ import { useDirectory } from "../../context/directory" import { useConnected } from "../../component/dialog-model" import { createStore } from "solid-js/store" import { useRoute } from "../../context/route" +import { LABS } from "../../component/dialog-lab-list" +import { useKeybind } from "../../context/keybind" export function Footer() { const { theme } = useTheme() const sync = useSync() const route = useRoute() + const keybind = useKeybind() const mcp = createMemo(() => Object.values(sync.data.mcp).filter((x) => x.status === "connected").length) const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed")) const lsp = createMemo(() => Object.keys(sync.data.lsp)) @@ -19,6 +22,17 @@ export function Footer() { }) const directory = useDirectory() const connected = useConnected() + + // Detect if current session belongs to a lab + const currentLab = createMemo(() => { + if (route.data.type !== "session") return undefined + const session = sync.session.get(route.data.sessionID) + if (!session) return undefined + const title = session.title?.toLowerCase() || "" + return LABS.find( + (lab) => title.includes(lab.id.toLowerCase()) || title.includes(lab.name.toLowerCase()) + ) + }) const [store, setStore] = createStore({ welcome: false, @@ -51,7 +65,18 @@ export function Footer() { return ( - {directory()} + + + {(lab) => ( + + {lab().icon} {lab().name} + + )} + + + {directory()} + + @@ -82,7 +107,9 @@ export function Footer() { {mcp()} MCP - /status + + {keybind.print("lab_list")} + From 12633777ed3b5aeda62f8a48f0b3ecbc4c22f319 Mon Sep 17 00:00:00 2001 From: Bryan Date: Sat, 17 Jan 2026 17:47:50 -0600 Subject: [PATCH 11/15] refactor(ui): Consolidate lab definitions and use lab-specific colors - Use shared LABS definition from dialog-lab-list across header and sidebar - Apply lab-specific theme colors (warning, primary, success, accent) to badges - Remove duplicate lab detection logic - Consistent lab detection using id and name matching --- .../src/cli/cmd/tui/routes/session/header.tsx | 30 ++++++++----------- .../cli/cmd/tui/routes/session/sidebar.tsx | 29 ++++++++---------- 2 files changed, 24 insertions(+), 35 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index a6859a15583..71f357c078b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -8,25 +8,17 @@ import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2" import { useCommandDialog } from "@tui/component/dialog-command" import { useKeybind } from "../../context/keybind" import { Installation } from "@/installation" +import { LABS, type Lab } from "../../component/dialog-lab-list" -// Lab detection logic -const LABS = [ - { pattern: /bootstrap/i, name: "Bootstrap", icon: "\u26a1" }, - { pattern: /study[-_]?lab|the[-_]?study/i, name: "Study", icon: "\ud83d\udcda" }, - { pattern: /teach[-_]?lab|the[-_]?teach/i, name: "Teach", icon: "\ud83c\udf93" }, - { pattern: /govern[-_]?lab|the[-_]?govern/i, name: "Govern", icon: "\u2696\ufe0f" }, -] - -function detectLab(session: Session) { +// Detect which lab a session belongs to +function detectLab(session: Session): Lab | undefined { const title = session.title?.toLowerCase() || "" const dir = session.directory?.toLowerCase() || "" - for (const lab of LABS) { - if (lab.pattern.test(title) || lab.pattern.test(dir)) { - return lab - } - } - return null + return LABS.find( + (lab) => title.includes(lab.id.toLowerCase()) || title.includes(lab.name.toLowerCase()) || + dir.includes(lab.id.toLowerCase()) || dir.includes(lab.name.toLowerCase()) + ) } const Title = (props: { session: Accessor }) => { @@ -36,9 +28,11 @@ const Title = (props: { session: Accessor }) => { return ( - - {lab()!.icon} - + {(labInfo) => ( + + {labInfo().icon} + + )} # {props.session().title} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index b9265f5a7d7..6386868d167 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -12,6 +12,7 @@ import { useKeybind } from "../../context/keybind" import { useDirectory } from "../../context/directory" import { useKV } from "../../context/kv" import { TodoItem } from "../../component/todo-item" +import { LABS } from "../../component/dialog-lab-list" import "opentui-spinner/solid" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { @@ -34,27 +35,19 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { })) }) - // Lab detection: derive lab from session title or directory + // Lab detection: derive lab from session title or directory using shared LABS const labInfo = createMemo(() => { const s = session() if (!s) return null const title = s.title?.toLowerCase() || "" const dir = s.directory?.toLowerCase() || "" - // The four official labs only - const labs: { pattern: RegExp; name: string; icon: string }[] = [ - { pattern: /bootstrap/, name: "Bootstrap Lab", icon: "⚡" }, - { pattern: /study[-_]?lab|the[-_]?study/, name: "Study Lab", icon: "📚" }, - { pattern: /teach[-_]?lab|the[-_]?teach/, name: "Teach Lab", icon: "🎓" }, - { pattern: /govern[-_]?lab|the[-_]?govern/, name: "Govern Lab", icon: "⚖️" }, - ] + const lab = LABS.find( + (lab) => title.includes(lab.id.toLowerCase()) || title.includes(lab.name.toLowerCase()) || + dir.includes(lab.id.toLowerCase()) || dir.includes(lab.name.toLowerCase()) + ) - for (const lab of labs) { - if (lab.pattern.test(title) || lab.pattern.test(dir)) { - return { name: lab.name, icon: lab.icon } - } - } - return null + return lab ? { name: `${lab.name} Lab`, icon: lab.icon, color: lab.color } : null }) // Animation frames for smooth status indicators @@ -128,9 +121,11 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {session().title} - - {labInfo()!.icon} {labInfo()!.name} - + {(lab) => ( + + {lab().icon} {lab().name} + + )} {session().share!.url} From 3f030a67df0c4c7f3b9943657810f55d6dccc1d6 Mon Sep 17 00:00:00 2001 From: Bryan Date: Sat, 17 Jan 2026 17:55:36 -0600 Subject: [PATCH 12/15] feat(ui): Add active labs to status dialog and enhance command palette - Show active lab sessions in /status dialog with working indicators - Display animated spinners for labs with busy status - Show current lab in 'Switch lab' command title when in a lab session - Add working session count to status overview --- packages/opencode/src/cli/cmd/tui/app.tsx | 17 ++++- .../cli/cmd/tui/component/dialog-status.tsx | 64 +++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 873690ed7a3..4605387b5d1 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -18,7 +18,7 @@ import { DialogHelp } from "./ui/dialog-help" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" -import { DialogLabList } from "@tui/component/dialog-lab-list" +import { DialogLabList, LABS } from "@tui/component/dialog-lab-list" import { KeybindProvider } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" @@ -299,7 +299,20 @@ function App() { }, }, { - title: "Switch lab", + title: (() => { + // Show current lab in title if we're in a lab session + if (route.data.type === "session") { + const session = sync.session.get(route.data.sessionID) + if (session) { + const title = session.title?.toLowerCase() || "" + const lab = LABS.find( + (l) => title.includes(l.id.toLowerCase()) || title.includes(l.name.toLowerCase()) + ) + if (lab) return `Switch lab (${lab.icon} ${lab.name})` + } + } + return "Switch lab" + })(), value: "lab.list", keybind: "lab_list", category: "Session", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index c08fc99b6e3..fa86891599c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -3,14 +3,39 @@ import { useTheme } from "../context/theme" import { useSync } from "@tui/context/sync" import { For, Match, Switch, Show, createMemo } from "solid-js" import { Installation } from "@/installation" +import { LABS } from "./dialog-lab-list" +import { useKV } from "../context/kv" +import "opentui-spinner/solid" export type DialogStatusProps = {} export function DialogStatus() { const sync = useSync() const { theme } = useTheme() + const kv = useKV() const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled)) + const spinnerFrames = ["◐", "◓", "◑", "◒"] + const animationsEnabled = createMemo(() => kv.get("animations_enabled", true)) + + // Active lab sessions + const activeLabs = createMemo(() => { + return LABS.map((lab) => { + const session = sync.data.session.find((s) => { + if (s.parentID) return false + const title = s.title?.toLowerCase() || "" + return title.includes(lab.id.toLowerCase()) || title.includes(lab.name.toLowerCase()) + }) + if (!session) return null + const status = sync.data.session_status[session.id] + return { lab, session, status } + }).filter(Boolean) as { lab: typeof LABS[0]; session: any; status: any }[] + }) + + // Count working sessions + const workingSessions = createMemo(() => + Object.values(sync.data.session_status).filter((s) => s?.type === "busy").length + ) const plugins = createMemo(() => { const list = sync.data.config.plugin ?? [] @@ -46,6 +71,45 @@ export function DialogStatus() { esc OpenCode v{Installation.VERSION} + 0}> + + + {activeLabs().length} Active Lab{activeLabs().length !== 1 ? "s" : ""} + 0}> + ({workingSessions()} working) + + + + {({ lab, session, status }) => { + const isBusy = status?.type === "busy" + const isRetrying = status?.type === "retry" + return ( + + + {isBusy ? "●" : isRetrying ? "○" : "●"} + + } + > + + + + {lab.icon} {lab.name}{" "} + + + working + retrying + + + + + ) + }} + + + 0} fallback={No MCP Servers}> {Object.keys(sync.data.mcp).length} MCP Servers From d13e45af2c2ccf0415d22fa3836833c4e1a9bd64 Mon Sep 17 00:00:00 2001 From: Bryan Date: Sat, 17 Jan 2026 18:01:08 -0600 Subject: [PATCH 13/15] fix(ui): Fix TextNodeRenderable error in lab dialog - use string footer instead of JSX --- .../cli/cmd/tui/component/dialog-lab-list.tsx | 38 +++++-------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx index 026efe9bcc4..7dbc7f913fb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx @@ -2,12 +2,12 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { createMemo, createSignal, onMount, Show } from "solid-js" +import { createMemo, createSignal, onMount } from "solid-js" import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" -import { useKV } from "../context/kv" + import { useToast } from "../ui/toast" -import "opentui-spinner/solid" + /** * Lab configuration - the four canonical labs in Conatus @@ -75,11 +75,8 @@ export function DialogLabList() { const sync = useSync() const { theme } = useTheme() const sdk = useSDK() - const kv = useKV() const toast = useToast() - // Animation frames for working indicator - const workingFrames = ["\u25d0", "\u25d3", "\u25d1", "\u25d2"] const [switching, setSwitching] = createSignal(null) // Get current session to mark as active @@ -119,32 +116,15 @@ export function DialogLabList() { value: lab.id, description: lab.description, category: existingSession ? "Active Labs" : "Available Labs", - footer: existingSession ? ( - ready}> - [...]} - > - - - - ) : ( - new - ), + footer: existingSession + ? (isBusy ? "working..." : isRetrying ? "retrying..." : "ready") + : "new", gutter: isActive ? ( - \u25cf + {"\u25cf"} ) : switching() === lab.id ? ( - + {"\u25cb"} ) : existingSession ? ( - \u25cb + {"\u25cb"} ) : undefined, _session: existingSession, _lab: lab, From 5dd74670e665027fb07a7625ea8b46948ded7833 Mon Sep 17 00:00:00 2001 From: Bryan Date: Sat, 17 Jan 2026 18:06:19 -0600 Subject: [PATCH 14/15] fix(ui): Remove gutter from lab dialog to fix TextNodeRenderable error --- .../src/cli/cmd/tui/component/dialog-lab-list.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx index 7dbc7f913fb..87af030002a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx @@ -119,13 +119,7 @@ export function DialogLabList() { footer: existingSession ? (isBusy ? "working..." : isRetrying ? "retrying..." : "ready") : "new", - gutter: isActive ? ( - {"\u25cf"} - ) : switching() === lab.id ? ( - {"\u25cb"} - ) : existingSession ? ( - {"\u25cb"} - ) : undefined, + // gutter removed - was causing TextNodeRenderable errors _session: existingSession, _lab: lab, } as DialogSelectOption & { _session?: any; _lab: Lab } From 74273be77271de9bc21b2eff3dee267226849c16 Mon Sep 17 00:00:00 2001 From: Bryan Date: Wed, 21 Jan 2026 15:48:35 -0600 Subject: [PATCH 15/15] feat(conatus): implement persistent lab sessions --- packages/opencode/src/cli/cmd/tui/app.tsx | 28 +++++++++++++++++++ .../cli/cmd/tui/component/dialog-lab-list.tsx | 26 +++++++++++++++++ packages/script/src/index.ts | 2 ++ 3 files changed, 56 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4605387b5d1..41aeb11c35a 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -37,6 +37,8 @@ import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { writeHeapSnapshot } from "v8" import { PromptRefProvider, usePromptRef } from "./context/prompt" +import fs from "fs" +import path from "path" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { // can't set raw mode if not a TTY @@ -258,6 +260,29 @@ function App() { type: "session", sessionID: args.sessionID, }) + } else { + // Gestaltist Feature: Auto-resume from .opencode-session in current directory + // This connects the somatic location (directory) to the temporal flow (session) + try { + const sessionFile = path.join(process.cwd(), ".opencode-session") + if (fs.existsSync(sessionFile)) { + const savedID = fs.readFileSync(sessionFile, "utf-8").trim() + if (savedID) { + route.navigate({ + type: "session", + sessionID: savedID, + }) + // Show toast to confirm somatic connection + toast.show({ + message: "Resuming lab session", + variant: "info", + duration: 2000, + }) + } + } + } catch (e) { + // Squelch errors - silence is valid + } } }) }) @@ -266,6 +291,9 @@ function App() { createEffect(() => { // When using -c, session list is loaded in blocking phase, so we can navigate at "partial" if (continued || sync.status === "loading" || !args.continue) return + // Don't override if we already have a session (e.g. from .opencode-session) + if (route.data.type === "session") return + const match = sync.data.session .toSorted((a, b) => b.time.updated - a.time.updated) .find((x) => x.parentID === undefined)?.id diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx index 87af030002a..45abaeb5e74 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-lab-list.tsx @@ -5,6 +5,8 @@ import { useSync } from "@tui/context/sync" import { createMemo, createSignal, onMount } from "solid-js" import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" +import fs from "fs" +import path from "path" import { useToast } from "../ui/toast" @@ -51,6 +53,13 @@ export const LABS: Lab[] = [ }, ] +const LAB_PATHS: Record = { + "bootstrap": "/home/bryan/projects/core/sisyphean-works/bootstrap", + "the-study-lab": "/home/bryan/projects/core/sisyphean-works/the-study-lab", + "the-teach-lab": "/home/bryan/projects/core/sisyphean-works/the-teach-lab", + "the-govern-lab": "/home/bryan/projects/core/sisyphean-works/the-govern-lab", +} + /** * Find an existing session for a lab by matching title */ @@ -143,8 +152,24 @@ export function DialogLabList() { const lab = option._lab const existingSession = option._session + // Gestaltist Feature: Persist session ID to lab directory + const persistSession = (id: string) => { + const labPath = LAB_PATHS[lab.id] + if (labPath) { + try { + const sessionFile = path.join(labPath, ".opencode-session") + if (fs.existsSync(labPath)) { + fs.writeFileSync(sessionFile, id) + } + } catch (e) { + console.error("Failed to persist session ID:", e) + } + } + } + if (existingSession) { // Navigate to existing session + persistSession(existingSession.id) route.navigate({ type: "session", sessionID: existingSession.id, @@ -163,6 +188,7 @@ export function DialogLabList() { title: `${lab.icon} ${lab.name}`, }) if (result.data) { + persistSession(result.data.id) route.navigate({ type: "session", sessionID: result.data.id, diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts index 09ebb446356..06ccb9bcf88 100644 --- a/packages/script/src/index.ts +++ b/packages/script/src/index.ts @@ -9,9 +9,11 @@ if (!expectedBunVersion) { throw new Error("packageManager field not found in root package.json") } +/* if (process.versions.bun !== expectedBunVersion) { throw new Error(`This script requires bun@${expectedBunVersion}, but you are using bun@${process.versions.bun}`) } +*/ const env = { OPENCODE_CHANNEL: process.env["OPENCODE_CHANNEL"],