diff --git a/src-tauri/src/harness.rs b/src-tauri/src/harness.rs index b61fa28e..b0a97def 100644 --- a/src-tauri/src/harness.rs +++ b/src-tauri/src/harness.rs @@ -306,6 +306,19 @@ pub fn harness_resolve_grok() -> Result { }) } +/// Resolve the OpenCrabs CLI (`opencrabs`). +#[tauri::command(async)] +pub fn harness_resolve_opencrabs() -> Result { + resolve_opencrabs() + .map(|path| CursorBinary { + path: path.to_string_lossy().into_owned(), + }) + .ok_or_else(|| { + "OpenCrabs CLI not found. Install OpenCrabs from https://github.com/opencrabs/opencrabs, then retry." + .into() + }) +} + /// Bind an ephemeral loopback port for `opencode serve`. #[tauri::command] pub fn harness_free_port() -> Result { @@ -1412,6 +1425,35 @@ fn resolve_grok() -> Option { first_binary_matching(candidates, is_grok_agent) } +fn resolve_opencrabs() -> Option { + let home = dirs_home().map(PathBuf::from); + let mut candidates: Vec = Vec::new(); + + if let Some(home) = &home { + candidates.push(home.join(".opencrabs/bin/opencrabs")); + candidates.push(home.join(".local/bin/opencrabs")); + candidates.push(home.join(".cargo/bin/opencrabs")); + candidates.push(home.join(".npm-global/bin/opencrabs")); + candidates.push(home.join("n/bin/opencrabs")); + } + #[cfg(target_os = "macos")] + candidates.push(PathBuf::from("/opt/homebrew/bin/opencrabs")); + candidates.push(PathBuf::from("/usr/local/bin/opencrabs")); + candidates.push(PathBuf::from("/usr/bin/opencrabs")); + candidates.push(PathBuf::from("/snap/bin/opencrabs")); + if let Some(from_shell) = which_via_login_shell("opencrabs") { + candidates.push(from_shell); + } + + first_binary_matching(candidates, is_opencrabs_binary) +} + +/// No same-named collision is known for `opencrabs`; an executable with the +/// right name is the agent CLI. +fn is_opencrabs_binary(path: &Path) -> bool { + binary_name_eq(path, "opencrabs") +} + fn is_pi_coding_agent(path: &Path) -> bool { if !path.is_file() { return false; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 09b39dcf..fe5c697f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -316,6 +316,7 @@ pub fn run() { harness::harness_resolve_pi, harness::harness_resolve_fx, harness::harness_resolve_grok, + harness::harness_resolve_opencrabs, harness::harness_free_port, harness::harness_spawn, harness::harness_write, diff --git a/src/assets/providers/opencrabs.svg b/src/assets/providers/opencrabs.svg new file mode 100644 index 00000000..46b226da --- /dev/null +++ b/src/assets/providers/opencrabs.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/chrome/HarnessIcon.tsx b/src/chrome/HarnessIcon.tsx index e69fd963..c88fad41 100644 --- a/src/chrome/HarnessIcon.tsx +++ b/src/chrome/HarnessIcon.tsx @@ -5,6 +5,7 @@ import cursor from "../assets/providers/cursor.svg"; import fx from "../assets/providers/fx.svg"; import grok from "../assets/providers/grok.svg"; import omp from "../assets/providers/omp.svg"; +import opencrabs from "../assets/providers/opencrabs.svg"; import opencode from "../assets/providers/opencode.svg"; import pi from "../assets/providers/pi.svg"; import type { HarnessId } from "../lib/session"; @@ -18,6 +19,7 @@ export const HARNESS_ICONS: Record = { pi, omp, fx, + opencrabs, }; /** White marks that must follow `currentColor` so they stay visible in light mode. */ diff --git a/src/lib/attachments.ts b/src/lib/attachments.ts index 37b373ad..9769eed8 100644 --- a/src/lib/attachments.ts +++ b/src/lib/attachments.ts @@ -479,7 +479,7 @@ function fallbackName(mimeType: string): string { return "attachment"; } -function fileUri(path: string): string { +export function fileUri(path: string): string { const normalized = path.replace(/\\/g, "/"); const abs = normalized.startsWith("/") ? normalized : `/${normalized}`; return `file://${abs.split("/").map(encodeURIComponent).join("/")}`; diff --git a/src/lib/harness/availability.ts b/src/lib/harness/availability.ts index f7d5df20..9c209617 100644 --- a/src/lib/harness/availability.ts +++ b/src/lib/harness/availability.ts @@ -8,6 +8,7 @@ import { resolveGrokBinary, resolveOmpBinary, resolveOpenCodeBinary, + resolveOpenCrabsBinary, resolvePiBinary, } from "./child"; import { isLiveHarness } from "./registry"; @@ -30,6 +31,10 @@ const CLI: Record = { pi: { name: "Pi CLI", install: "npm i -g @earendil-works/pi-coding-agent" }, omp: { name: "omp CLI", install: "curl -fsSL https://omp.sh/install | sh" }, fx: { name: "fx CLI", install: "curl -fsSL https://fx.sh/setup.sh | bash" }, + opencrabs: { + name: "OpenCrabs CLI", + install: "https://github.com/opencrabs/opencrabs", + }, }; let availability: HarnessAvailability = { @@ -41,6 +46,7 @@ let availability: HarnessAvailability = { pi: false, omp: false, fx: false, + opencrabs: false, }; let version = 0; let inflight: Promise | null = null; @@ -159,6 +165,14 @@ export function probeHarnessAvailability( return [id, false] as const; } } + if (id === "opencrabs") { + try { + await resolveOpenCrabsBinary(); + return [id, true] as const; + } catch { + return [id, false] as const; + } + } return [id, false] as const; }), ) diff --git a/src/lib/harness/child.ts b/src/lib/harness/child.ts index 240a5525..57e3aae6 100644 --- a/src/lib/harness/child.ts +++ b/src/lib/harness/child.ts @@ -313,6 +313,10 @@ export function resolveGrokBinary(): Promise<{ path: string }> { return invoke("harness_resolve_grok"); } +export function resolveOpenCrabsBinary(): Promise<{ path: string }> { + return invoke("harness_resolve_opencrabs"); +} + export function freeHarnessPort(): Promise { return invoke("harness_free_port"); } diff --git a/src/lib/harness/index.ts b/src/lib/harness/index.ts index 20f7444e..7df48c11 100644 --- a/src/lib/harness/index.ts +++ b/src/lib/harness/index.ts @@ -76,6 +76,14 @@ export { forgetGrokSession, bindGrokSession, } from "./grok"; +export { + sendOpenCrabsTurn, + cancelOpenCrabsTurn, + respondOpenCrabsApproval, + stopOpenCrabsSession, + forgetOpenCrabsSession, + bindOpenCrabsSession, +} from "./opencrabs"; export { generateCursorSessionTitle } from "./cursorTitle"; export { generateCodexSessionTitle } from "./codexTitle"; export { generateOpenCodeSessionTitle } from "./opencodeTitle"; diff --git a/src/lib/harness/opencrabs.ts b/src/lib/harness/opencrabs.ts new file mode 100644 index 00000000..071a6601 --- /dev/null +++ b/src/lib/harness/opencrabs.ts @@ -0,0 +1,594 @@ +import { nativeModelId, setHarnessModels } from "../models"; +import { openCrabsPromptBlocks } from "./opencrabsPrompt"; +import type { RuntimeMode } from "../session"; +import { AcpClient, type AcpHandlers } from "./acp"; +import { + killChild, + resolveOpenCrabsBinary, + spawnChild, + unwatchChild, + watchChild, +} from "./child"; +import { + nativeCommandInvocation, + type NativeCommand, + type NativeCommandProvider, +} from "./nativeCommands"; +import { + autoPermissionOption, + eventsFromAcpUpdate, + permissionOptionId, + permissionRequestFromAcp, + modelsFromSessionNew, + nativeCommandsFromUpdate, + sessionIdFromResult, +} from "./opencrabsProtocol"; +import type { + ApprovalDecision, + CompactContextInput, + HarnessEvent, + SendTurnInput, + SteerTurnInput, +} from "./types"; + +type Live = { + acp: AcpClient; + acpSessionId: string; + threadId: string; + cwd: string; + muteUpdates: boolean; + cancelled: boolean; + runtimeMode: RuntimeMode; + planning: boolean; + onEvent: (event: HarnessEvent) => void; + approvals: Map void>; + turns: Promise; +}; + +type Resume = { + acpSessionId: string; + cwd: string; +}; + +// OpenCrabs boots a full runtime (config, brain files, provider handshake) +// before it can answer `initialize`, so give it more room than a thin CLI. +const INIT_TIMEOUT_MS = 30_000; +const SESSION_TIMEOUT_MS = 45_000; +const CONTROL_TIMEOUT_MS = 15_000; +const PROMPT_TIMEOUT_MS = 30 * 60_000; + +const SERVER_HELP = + "The OpenCrabs ACP server mode is paired work that may not be released yet. " + + "Check that your opencrabs build supports `opencrabs acp`."; + +const CLIENT_CAPABILITIES = { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, +}; + +const liveByThread = new Map(); +const resumeByThread = new Map(); +const cancelledThreads = new Set(); + +/** Slash commands pushed by the server, cached per MonoCode thread. */ +const commandsByThread = new Map(); +const commandSubscribers = new Map void>>(); + +function cacheNativeCommands( + live: Live, + rows: { name: string; description: string }[], +): void { + const commands: NativeCommand[] = rows.map((row) => ({ + name: row.name, + description: row.description, + invocation: nativeCommandInvocation("opencrabs", row.name), + source: "opencrabs" as const, + })); + commandsByThread.set(live.threadId, commands); + commandSubscribers.get(live.threadId)?.forEach((cb) => cb(commands)); +} + +/** + * The server's `available_commands_update` push, surfaced as a command + * provider: built-ins, skills, and the user's own commands.toml entries are + * slash-able from the picker once a session is live. + */ +export const openCrabsCommands: NativeCommandProvider = { + discover: async (context) => + commandsByThread.get(context.sessionId ?? "") ?? [], + subscribe: (context, onCommands) => { + const key = context.sessionId ?? ""; + const set = commandSubscribers.get(key) ?? new Set(); + set.add(onCommands); + commandSubscribers.set(key, set); + const cached = commandsByThread.get(key); + if (cached) onCommands(cached); + return () => { + set.delete(onCommands); + }; + }, + rawSlashCommands: true, +}; + +/** + * Live OpenCrabs adapter. Spawns `opencrabs acp` and talks Agent Client + * Protocol over stdio. Permission requests surface in the UI unless the + * runtime mode auto-answers them. + */ +export async function sendOpenCrabsTurn(input: SendTurnInput): Promise { + let live: Live; + try { + live = await ensureLive(input); + } catch (error) { + cancelledThreads.delete(input.sessionId); + throw error; + } + if (cancelledThreads.delete(input.sessionId)) return; + + live.onEvent = input.onEvent; + live.runtimeMode = input.runtimeMode; + live.planning = input.intent === "plan"; + live.turns = live.turns + .catch(() => undefined) + .then(async () => { + live.cancelled = false; + live.muteUpdates = false; + try { + await applyModelSelection(live, input); + await applyRuntimeMode(live, input); + if (live.cancelled) return; + await prompt(live, input); + } catch (error) { + if (live.cancelled) return; + throw error; + } + }); + try { + await live.turns; + } catch (error) { + // A timed-out or failed turn leaves the child's protocol state unknowable. + // Keep the provider session id, but recycle the process so the next turn + // resumes on a fresh transport instead of a wedged one. + if (liveByThread.get(input.sessionId) === live) { + await stopOpenCrabsSession(input.sessionId); + } + throw error; + } +} + +export async function steerOpenCrabsTurn(input: SteerTurnInput): Promise { + const live = liveByThread.get(input.sessionId); + if (!live) throw new Error("No active OpenCrabs session"); + const blocks = await openCrabsPromptBlocks(input.text, input.attachments); + if (blocks.length === 0) return; + await live.acp + .notify("_session/steer", { + sessionId: live.acpSessionId, + prompt: blocks, + }) + .catch(() => undefined); +} + +export function respondOpenCrabsApproval( + sessionId: string, + requestId: number, + decision: ApprovalDecision, +): void { + liveByThread.get(sessionId)?.approvals.get(requestId)?.(decision); +} + +/** Abort the in-flight prompt without tearing down the ACP session. */ +export async function cancelOpenCrabsTurn(sessionId: string): Promise { + const live = liveByThread.get(sessionId); + if (!live) { + cancelledThreads.add(sessionId); + return; + } + live.cancelled = true; + live.muteUpdates = true; + for (const [, resolve] of live.approvals) resolve("deny"); + live.approvals.clear(); + await live.acp + .notify("session/cancel", { sessionId: live.acpSessionId }) + .catch(() => undefined); + live.acp.rejectPending(new Error("cancelled")); +} + +/** Kill the process but keep the ACP session id so we can session/load. */ +export async function stopOpenCrabsSession(sessionId: string): Promise { + cancelledThreads.delete(sessionId); + const live = liveByThread.get(sessionId); + liveByThread.delete(sessionId); + if (live) { + live.muteUpdates = true; + for (const [, resolve] of live.approvals) resolve("deny"); + live.approvals.clear(); + } + live?.acp.close(); + unwatchChild(sessionId); + await killChild(sessionId).catch(() => undefined); +} + +/** Delete or idle detach — drop the OpenCrabs conversation too. */ +export async function forgetOpenCrabsSession(sessionId: string): Promise { + resumeByThread.delete(sessionId); + await stopOpenCrabsSession(sessionId); +} + +/** + * Compact the session's context window via `session/compact`. The server + * runs its native summarization turn; the request resolves when it ends. + * Compaction of a long conversation is a full turn, so it rides the turn + * queue and the prompt timeout rather than the control timeout. + */ +export async function compactOpenCrabsContext( + input: CompactContextInput, +): Promise { + const live = liveByThread.get(input.sessionId) ?? + (await ensureLive({ ...input, text: "" })); + live.onEvent = input.onEvent; + live.turns = live.turns + .catch(() => undefined) + .then(async () => { + if (live.cancelled) return; + await live.acp.request( + "session/compact", + { sessionId: live.acpSessionId }, + PROMPT_TIMEOUT_MS, + ); + }); + await live.turns; +} + +/** Seed ACP resume state for a restored MonoCode session. */ +export function bindOpenCrabsSession( + threadId: string, + acpSessionId: string, + cwd: string, +): void { + const sessionId = acpSessionId.trim(); + if (!threadId || !sessionId || !cwd.trim()) return; + resumeByThread.set(threadId, { acpSessionId: sessionId, cwd }); +} + +async function ensureLive(input: SendTurnInput): Promise { + const existing = liveByThread.get(input.sessionId); + if (existing && existing.cwd === input.cwd) { + existing.onEvent = input.onEvent; + existing.runtimeMode = input.runtimeMode; + existing.planning = input.intent === "plan"; + return existing; + } + if (existing) { + resumeByThread.delete(input.sessionId); + await stopOpenCrabsSession(input.sessionId); + } + + const resume = resumeByThread.get(input.sessionId); + const canLoad = resume != null && resume.cwd === input.cwd; + if (resume && resume.cwd !== input.cwd) { + resumeByThread.delete(input.sessionId); + } + + const { path } = await resolveOpenCrabsBinary(); + const handlers: AcpHandlers = {}; + const acp = new AcpClient(input.sessionId, handlers); + const liveRef: { current: Live | null } = { current: null }; + const muteGate = { current: false }; + + handlers.onNotification = (method, params) => { + if (muteGate.current) return; + const live = liveRef.current; + if (!live || live.muteUpdates) return; + handleNotification(live, method, params); + }; + handlers.onRequest = (id, method, params) => { + const live = liveRef.current; + if (!live) { + void acp + .respondError(id, { + code: -32601, + message: `Method not found: ${method}`, + }) + .catch(() => undefined); + return; + } + void handleRequest(live, id, method, params); + }; + + // ensureLive runs once per session, so these handlers outlive the turn that + // created them. Route through liveRef so events after turn 1 reach the + // current turn's listener instead of a finished one. + const emit = (event: HarnessEvent) => { + (liveRef.current?.onEvent ?? input.onEvent)(event); + }; + + watchChild( + input.sessionId, + (line) => acp.pushLine(line), + (code) => { + acp.close(new Error("opencrabs exited")); + liveByThread.delete(input.sessionId); + emit({ type: "session.ended", code }); + }, + (line) => { + console.debug("[monocode] opencrabs stderr", line); + }, + ); + + await spawnChild(input.sessionId, path, spawnArgs(input.model), input.cwd); + + try { + await acp.request( + "initialize", + { + protocolVersion: 1, + clientCapabilities: CLIENT_CAPABILITIES, + clientInfo: { name: "monocode", version: "0.1.0" }, + }, + INIT_TIMEOUT_MS, + ); + + let setup: unknown; + let acpSessionId: string | undefined; + let didLoad = false; + + if (canLoad && resume) { + muteGate.current = true; + try { + setup = await acp.request( + "session/load", + { + sessionId: resume.acpSessionId, + cwd: input.cwd, + mcpServers: [], + }, + SESSION_TIMEOUT_MS, + ); + acpSessionId = sessionIdFromResult(setup) ?? resume.acpSessionId; + didLoad = true; + } catch { + setup = undefined; + acpSessionId = undefined; + didLoad = false; + } finally { + muteGate.current = false; + } + } + + if (!acpSessionId) { + setup = await acp.request( + "session/new", + { cwd: input.cwd, mcpServers: [] }, + SESSION_TIMEOUT_MS, + ); + acpSessionId = sessionIdFromResult(setup); + } + if (!acpSessionId) throw new Error("opencrabs did not return a session id"); + + // Live catalog: replace the static "default" picker entry with the + // server's configured provider/model pairs. + const catalog = modelsFromSessionNew(setup); + if (catalog.available.length > 0) { + setHarnessModels( + "opencrabs", + catalog.available.map((entry) => ({ + id: `opencrabs:${entry.modelId}`, + harness: "opencrabs" as const, + name: entry.name, + nativeId: entry.modelId, + })), + ); + } + + const live: Live = { + acp, + acpSessionId, + threadId: input.sessionId, + cwd: input.cwd, + muteUpdates: didLoad, + cancelled: false, + runtimeMode: input.runtimeMode, + planning: input.intent === "plan", + onEvent: input.onEvent, + approvals: new Map(), + turns: Promise.resolve(), + }; + liveRef.current = live; + liveByThread.set(input.sessionId, live); + resumeByThread.set(input.sessionId, { + acpSessionId, + cwd: input.cwd, + }); + live.onEvent({ + type: "session.providerBound", + providerSessionId: acpSessionId, + }); + // Reflect the server's current model in the thread badge — on load this + // is the restored per-session pick, so the picker survives restarts. + if (catalog.current) { + live.onEvent({ + type: "session.configChanged", + model: `opencrabs:${catalog.current}`, + }); + } + live.onEvent({ type: "session.started" }); + return live; + } catch (error) { + acp.close(error instanceof Error ? error : new Error(String(error))); + await stopOpenCrabsSession(input.sessionId); + throw error; + } +} + +/** + * Model selection is best-effort: the static catalog ships only `default` + * (empty native id, skipped here), while live catalog entries carry + * `provider/model` pairs the server routes through `session/set_model`. + */ +async function applyModelSelection( + live: Live, + input: SendTurnInput, +): Promise { + const base = nativeModelId(input.model).trim(); + if (!base) return; + try { + await live.acp.request( + "session/set_model", + { sessionId: live.acpSessionId, modelId: base }, + CONTROL_TIMEOUT_MS, + ); + // The badge only hears about model switches through configChanged — + // without it the picker and the turn can quietly disagree. + live.onEvent({ type: "session.configChanged", model: input.model }); + } catch { + // A failed set_model leaves the previous model in place; no event. + } +} + +function spawnArgs(model: string): string[] { + const native = nativeModelId(model).trim(); + return native ? ["acp", "--model", native] : ["acp"]; +} + +/** + * Push the runtime/plan mode server-side so the approval policy lives where + * the tools run. Client-side gating in handlePermission stays as backstop, + * and an older binary without set_mode support degrades to it. + */ +async function applyRuntimeMode( + live: Live, + input: SendTurnInput, +): Promise { + const modeId = input.intent === "plan" ? "plan" : input.runtimeMode; + await live.acp + .request( + "session/set_mode", + { sessionId: live.acpSessionId, modeId }, + CONTROL_TIMEOUT_MS, + ) + .catch((error: unknown) => { + const detail = error instanceof Error ? error.message : String(error); + console.debug("[monocode] opencrabs set_mode failed", detail); + if (/timed out|not running|exited|closed|pipe/i.test(detail)) throw error; + }); +} + +async function prompt(live: Live, input: SendTurnInput): Promise { + try { + const blocks = await openCrabsPromptBlocks(input.text, input.attachments); + if (blocks.length === 0) return; + await live.acp.request( + "session/prompt", + { + sessionId: live.acpSessionId, + prompt: blocks, + }, + PROMPT_TIMEOUT_MS, + ); + if (live.cancelled) return; + live.onEvent({ type: "message.completed" }); + live.onEvent({ type: "reasoning.completed" }); + } catch (error) { + if (live.cancelled) return; + const detail = error instanceof Error ? error.message : String(error); + live.onEvent({ + type: "session.error", + message: /timed out|not running|exited|closed|pipe|method not found/i.test( + detail, + ) + ? `${detail.trim()}\n\n${SERVER_HELP}` + : detail, + }); + throw error; + } +} + +function handleNotification(live: Live, method: string, params: unknown) { + if (method !== "session/update") return; + const commands = nativeCommandsFromUpdate(params); + if (commands) { + cacheNativeCommands(live, commands); + return; + } + for (const event of eventsFromAcpUpdate(params)) { + live.onEvent(event); + } +} + +async function handleRequest( + live: Live, + id: number, + method: string, + params: unknown, +) { + if (method === "session/request_permission") { + await handlePermission(live, id, params); + return; + } + await live.acp + .respondError(id, { + code: -32601, + message: `Method not found: ${method}`, + }) + .catch(() => undefined); +} + +async function handlePermission(live: Live, id: number, params: unknown) { + const request = permissionRequestFromAcp(params); + if (request.callId) { + live.onEvent({ + type: "tool.updated", + callId: request.callId, + title: request.title, + kind: request.kind, + preview: request.preview, + }); + } + + if (live.planning) { + const normalized = (request.kind ?? "").toLowerCase(); + const readOnly = normalized === "read" || normalized === "search"; + await live.acp.respond(id, { + outcome: { + outcome: "selected", + optionId: permissionOptionId(readOnly ? "allow" : "deny", request.optionIds), + }, + }); + return; + } + + const auto = autoPermissionOption( + live.runtimeMode, + request.kind, + request.optionIds, + ); + if (auto) { + await live.acp.respond(id, { + outcome: { outcome: "selected", optionId: auto }, + }); + return; + } + + live.onEvent({ + type: "approval.requested", + requestId: id, + title: request.title, + kind: request.kind, + callId: request.callId, + preview: request.preview, + }); + + const decision = await new Promise((resolve) => { + live.approvals.set(id, resolve); + }); + live.approvals.delete(id); + live.onEvent({ type: "approval.resolved", requestId: id, decision }); + + await live.acp.respond(id, { + outcome: { + outcome: "selected", + optionId: permissionOptionId(decision, request.optionIds), + }, + }); +} diff --git a/src/lib/harness/opencrabsAdapter.ts b/src/lib/harness/opencrabsAdapter.ts new file mode 100644 index 00000000..3c1010f0 --- /dev/null +++ b/src/lib/harness/opencrabsAdapter.ts @@ -0,0 +1,44 @@ +import { + bindOpenCrabsSession, + cancelOpenCrabsTurn, + compactOpenCrabsContext, + forgetOpenCrabsSession, + openCrabsCommands, + respondOpenCrabsApproval, + sendOpenCrabsTurn, + steerOpenCrabsTurn, + stopOpenCrabsSession, +} from "./opencrabs"; +import { + generateOpenCrabsBranchName, + generateOpenCrabsCommitMessage, + generateOpenCrabsPrContent, +} from "./opencrabsGit"; +import { generateOpenCrabsSessionTitle } from "./opencrabsTitle"; +import { registerHarness, type HarnessAdapter } from "./registry"; + +export const openCrabsAdapter: HarnessAdapter = { + id: "opencrabs", + live: true, + sendTurn: sendOpenCrabsTurn, + steerTurn: steerOpenCrabsTurn, + cancelTurn: cancelOpenCrabsTurn, + respondApproval: respondOpenCrabsApproval, + stopSession: stopOpenCrabsSession, + forgetSession: forgetOpenCrabsSession, + bindSession: bindOpenCrabsSession, + compactContext: compactOpenCrabsContext, + commands: openCrabsCommands, + generateTitle: generateOpenCrabsSessionTitle, + generateCommitMessage: generateOpenCrabsCommitMessage, + generatePrContent: generateOpenCrabsPrContent, + generateBranchName: generateOpenCrabsBranchName, +}; + +let registered = false; + +export function ensureOpenCrabsRegistered(): void { + if (registered) return; + registerHarness(openCrabsAdapter); + registered = true; +} diff --git a/src/lib/harness/opencrabsGit.ts b/src/lib/harness/opencrabsGit.ts new file mode 100644 index 00000000..7d24e7ee --- /dev/null +++ b/src/lib/harness/opencrabsGit.ts @@ -0,0 +1,87 @@ +import { gitRangeContext, gitStagedContext } from "../fs"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + formatCommitMessage, + parseBranchName, + parseCommitMessage, + parsePrContent, + type PrContent, +} from "../gitText"; +import { runOpenCrabsTextPrompt } from "./opencrabsText"; + +const GIT_TIMEOUT_MS = 90_000; + +export async function generateOpenCrabsCommitMessage( + cwd: string, +): Promise { + const context = await gitStagedContext(cwd); + const output = await runOpenCrabsTextPrompt({ + cwd, + prompt: buildCommitMessagePrompt({ + branch: context.branch, + stagedSummary: context.summary, + stagedPatch: context.patch, + }), + timeoutMs: GIT_TIMEOUT_MS, + }); + const parsed = parseCommitMessage(output); + if (parsed) return formatCommitMessage(parsed); + const snippet = output.trim().replace(/\s+/g, " ").slice(0, 240); + throw new Error( + snippet + ? `Could not generate a commit message. Model replied: ${snippet}` + : "Could not generate a commit message. OpenCrabs returned no text.", + ); +} + +export async function generateOpenCrabsPrContent( + cwd: string, +): Promise<(PrContent & { base: string; head: string }) | null> { + const range = await gitRangeContext(cwd); + let parsed: PrContent | null = null; + try { + const output = await runOpenCrabsTextPrompt({ + cwd, + prompt: buildPrContentPrompt({ + baseBranch: range.base, + headBranch: range.head, + commitSummary: range.commitSummary, + diffSummary: range.diffSummary, + diffPatch: range.diffPatch, + }), + timeoutMs: GIT_TIMEOUT_MS, + }); + parsed = parsePrContent(output); + } catch (error) { + console.debug("[monocode] opencrabs pr content", error); + } + const title = + parsed?.title || + range.commitSummary.split(/\r?\n/)[0]?.trim() || + `Update ${range.head}`; + return { + title, + body: parsed?.body || range.commitSummary.trim(), + base: range.base, + head: range.head, + }; +} + +export async function generateOpenCrabsBranchName( + cwd: string, + message: string, +): Promise { + try { + const output = await runOpenCrabsTextPrompt({ + cwd, + prompt: buildBranchNamePrompt(message), + timeoutMs: GIT_TIMEOUT_MS, + }); + return parseBranchName(output); + } catch (error) { + console.debug("[monocode] opencrabs branch name", error); + return null; + } +} diff --git a/src/lib/harness/opencrabsPrompt.test.ts b/src/lib/harness/opencrabsPrompt.test.ts new file mode 100644 index 00000000..43695a9f --- /dev/null +++ b/src/lib/harness/opencrabsPrompt.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Attachment } from "../session"; + +const invokeMock = vi.fn(); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (command: string, args: unknown) => invokeMock(command, args), +})); + +import { openCrabsPromptBlocks } from "./opencrabsPrompt"; + +function imageAttachment(over: Partial): Attachment { + return { + id: "att-1", + name: "shot.png", + mimeType: "image/png", + kind: "image", + size: 4, + ...over, + } as Attachment; +} + +describe("openCrabsPromptBlocks", () => { + beforeEach(() => { + invokeMock.mockReset(); + }); + + it("passes plain text through unchanged", async () => { + const blocks = await openCrabsPromptBlocks("hello", []); + expect(blocks).toEqual([{ type: "text", text: "hello" }]); + expect(invokeMock).not.toHaveBeenCalled(); + }); + + it("rewrites a disk-backed image as a resource_link without persisting", async () => { + const file = imageAttachment({ path: "/tmp/shot.png", data: "aW1hZ2U=" }); + const blocks = await openCrabsPromptBlocks("look", [file]); + expect(blocks[1]).toEqual({ + type: "resource_link", + uri: "file:///tmp/shot.png", + name: "shot.png", + mimeType: "image/png", + size: 4, + }); + expect(invokeMock).not.toHaveBeenCalled(); + }); + + it("persists a pasted blob and links the temp path", async () => { + invokeMock.mockResolvedValue("/tmp/monocode-attachments/1-2-pasted-image.png"); + const file = imageAttachment({ data: "aW1hZ2U=" }); + const blocks = await openCrabsPromptBlocks("", [file]); + expect(invokeMock).toHaveBeenCalledWith("write_attachment", { + name: "pasted-image.png", + data: "aW1hZ2U=", + }); + expect(blocks[0]).toEqual({ + type: "resource_link", + uri: "file:///tmp/monocode-attachments/1-2-pasted-image.png", + name: "pasted-image.png", + mimeType: "image/png", + size: 4, + }); + }); + + it("fails loud when a pasted blob cannot be persisted", async () => { + invokeMock.mockRejectedValue(new Error("disk full")); + const file = imageAttachment({ data: "aW1hZ2U=" }); + await expect(openCrabsPromptBlocks("", [file])).rejects.toThrow( + "disk full", + ); + }); + + it("leaves non-vision resource_link attachments untouched", async () => { + const file: Attachment = { + id: "att-2", + name: "notes.md", + mimeType: "text/markdown", + kind: "attachment", + size: 10, + path: "/tmp/notes.md", + } as Attachment; + const blocks = await openCrabsPromptBlocks("", [file]); + expect(blocks[0]).toEqual({ + type: "resource_link", + uri: "file:///tmp/notes.md", + name: "notes.md", + mimeType: "text/markdown", + size: 10, + }); + expect(invokeMock).not.toHaveBeenCalled(); + }); + + it("links a disk-backed audio attachment without persisting", async () => { + const file: Attachment = { + id: "att-3", + name: "voice.mp3", + mimeType: "audio/mpeg", + kind: "audio", + size: 42, + path: "/tmp/voice.mp3", + } as Attachment; + const blocks = await openCrabsPromptBlocks("transcribe this", [file]); + expect(blocks[1]).toEqual({ + type: "resource_link", + uri: "file:///tmp/voice.mp3", + name: "voice.mp3", + mimeType: "audio/mpeg", + size: 42, + }); + expect(invokeMock).not.toHaveBeenCalled(); + }); + + it("persists a pasted audio blob with a kind-specific name", async () => { + invokeMock.mockResolvedValue("/tmp/monocode-attachments/3-pasted-audio.mp3"); + const file: Attachment = { + id: "att-4", + name: "voice.mp3", + mimeType: "audio/mpeg", + kind: "audio", + size: 42, + data: "YXVkaW8=", + } as Attachment; + const blocks = await openCrabsPromptBlocks("", [file]); + expect(invokeMock).toHaveBeenCalledWith("write_attachment", { + name: "pasted-audio.mp3", + data: "YXVkaW8=", + }); + expect(blocks[0]).toEqual({ + type: "resource_link", + uri: "file:///tmp/monocode-attachments/3-pasted-audio.mp3", + name: "pasted-audio.mp3", + mimeType: "audio/mpeg", + size: 42, + }); + }); + + it("fails loud when an attachment has neither path nor data", async () => { + const file: Attachment = { + id: "att-5", + name: "ghost.pdf", + mimeType: "application/pdf", + kind: "file", + size: 1, + } as Attachment; + await expect(openCrabsPromptBlocks("", [file])).rejects.toThrow( + "no local file path or data", + ); + expect(invokeMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/harness/opencrabsPrompt.ts b/src/lib/harness/opencrabsPrompt.ts new file mode 100644 index 00000000..b2ec02a9 --- /dev/null +++ b/src/lib/harness/opencrabsPrompt.ts @@ -0,0 +1,81 @@ +import { invoke } from "@tauri-apps/api/core"; +import { fileUri, type PromptContentBlock } from "../attachments"; +import type { Attachment } from "../session"; + +const EXT_BY_MIME: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "audio/mpeg": "mp3", + "audio/mp3": "mp3", + "audio/wav": "wav", + "audio/ogg": "ogg", + "audio/m4a": "m4a", + "audio/webm": "webm", + "application/pdf": "pdf", + "text/plain": "txt", + "text/markdown": "md", +}; + +/** + * Prompt blocks shaped for `opencrabs acp`. + * + * The server declares `promptCapabilities.image: false` and reads + * `resource_link` blocks as on-disk path references, so every attachment + * kind — image, audio, file — is rewritten as a link. The rule is uniform: + * a disk path links as-is; a pasted blob is persisted through the shared + * `write_attachment` command first; an attachment with neither fails the + * send with a visible error. Nothing is silently dropped. + */ +export async function openCrabsPromptBlocks( + text: string, + attachments: Attachment[] = [], +): Promise { + const blocks: PromptContentBlock[] = []; + const trimmed = text.trim(); + if (trimmed) blocks.push({ type: "text", text: trimmed }); + for (const file of attachments) { + blocks.push(await attachmentBlock(file)); + } + return blocks; +} + +async function attachmentBlock(file: Attachment): Promise { + if (file.path?.trim()) { + return { + type: "resource_link", + uri: fileUri(file.path), + name: file.name, + mimeType: file.mimeType, + size: file.size, + }; + } + if (!file.data) { + throw new Error( + `Cannot attach ${JSON.stringify(file.name)}: no local file path or data is available. Attach the file again.`, + ); + } + const name = persistName(file); + const path = await invoke("write_attachment", { + name, + data: file.data, + }); + return { + type: "resource_link", + uri: fileUri(path), + name, + mimeType: file.mimeType, + size: file.size, + }; +} + +/** Pasted blobs get a generated name: kind plus the best extension guess. */ +function persistName(file: Attachment): string { + const ext = + EXT_BY_MIME[file.mimeType] ?? + (file.name.includes(".") ? file.name.split(".").pop() : undefined) ?? + "bin"; + return `pasted-${file.kind}.${ext}`; +} diff --git a/src/lib/harness/opencrabsProtocol.ts b/src/lib/harness/opencrabsProtocol.ts new file mode 100644 index 00000000..0cb989de --- /dev/null +++ b/src/lib/harness/opencrabsProtocol.ts @@ -0,0 +1,401 @@ +import type { RuntimeMode, ToolPreview } from "../session"; +import { normalizeTaskListStatus } from "../taskList"; +import type { ApprovalDecision, HarnessEvent } from "./types"; +import { + composeToolTitle, + extractSearchQuery, + extractShellCommand, + extractSkillName, + extractToolPreview, +} from "./preview"; + +export type OpenCrabsPermissionRequest = { + title: string; + kind?: string; + callId?: string; + preview?: ToolPreview; + optionIds: string[]; +}; + +/** + * ACP `session/update` params -> MonoCode harness events. + * + * Unlike fx, the OpenCrabs ACP server sends structured tool fields + * (kind/title/rawInput/locations), so the shared extraction in ./preview + * does the work and no harness-specific result mining is needed. + */ +export function eventsFromAcpUpdate(params: unknown): HarnessEvent[] { + const rec = asRecord(params); + const update = asRecord(rec?.update) ?? rec; + if (!update) return []; + const kind = String( + update.sessionUpdate ?? update.session_update ?? update.type ?? "", + ); + + if (kind === "agent_message_chunk" || kind === "agent_message") { + const text = textFromContent( + update.content ?? update.text, + kind === "agent_message" ? "\n" : "", + ); + return text ? [{ type: "message.delta", text }] : []; + } + + if (kind === "agent_thought_chunk" || kind === "agent_thought") { + const text = textFromContent( + update.content ?? update.text, + kind === "agent_thought" ? "\n" : "", + ); + return text ? [{ type: "reasoning.delta", text }] : []; + } + + if ( + kind === "tool_call" || + kind === "tool_call_update" || + kind === "tool_call_content_chunk" + ) { + const tool = + asRecord(update.toolCall) ?? asRecord(update.tool_call) ?? update; + const callId = String( + tool.toolCallId ?? + tool.tool_call_id ?? + update.toolCallId ?? + update.tool_call_id ?? + "", + ); + if (!callId) return []; + const toolKind = stringField(update, "kind") ?? stringField(tool, "kind"); + const status = stringField(update, "status") ?? stringField(tool, "status"); + const preview = extractToolPreview(update, tool); + const title = composeToolTitle({ + kind: toolKind, + title: toolLabel(update) ?? toolLabel(tool), + command: extractShellCommand( + update.rawInput, + tool.rawInput, + update.raw_input, + tool.raw_input, + update.input, + tool.input, + ), + skill: extractSkillName( + update.rawInput, + tool.rawInput, + update.raw_input, + tool.raw_input, + update.input, + tool.input, + ), + path: preview?.path, + query: + preview?.query ?? + extractSearchQuery( + update.rawInput ?? + tool.rawInput ?? + update.raw_input ?? + tool.raw_input ?? + update.input ?? + tool.input, + ), + previewKind: preview?.kind, + }); + return [ + { + type: "tool.updated", + callId, + title: title || toolLabel(update) || toolLabel(tool), + kind: toolKind, + status, + detail: toolDetail(update, tool), + preview, + }, + ]; + } + + if (kind === "plan" || kind === "current_plan") { + const event = planEvent(update); + return event ? [event] : []; + } + + const usage = usageFromUpdate(update); + return usage ? [usage] : []; +} + +export function permissionRequestFromAcp( + params: unknown, +): OpenCrabsPermissionRequest { + const rec = asRecord(params); + const subject = asRecord(rec?.subject); + const tool = + asRecord(rec?.toolCall) ?? + asRecord(rec?.tool_call) ?? + asRecord(subject?.toolCall) ?? + asRecord(subject) ?? + rec ?? + {}; + const command = stringField(subject ?? {}, "command"); + const kind = stringField(tool, "kind") ?? stringField(subject ?? {}, "kind"); + const preview = mergePreview( + extractToolPreview(tool, tool), + subject ? extractToolPreview(subject, subject) : undefined, + ); + const title = + composeToolTitle({ + kind, + title: toolLabel(tool) ?? toolLabel(subject ?? {}) ?? command, + command: command ?? extractShellCommand(tool, subject), + skill: extractSkillName(tool, subject), + path: preview?.path, + query: preview?.query ?? extractSearchQuery(tool), + previewKind: preview?.kind, + }) || "Permission"; + const options = Array.isArray(rec?.options) ? rec.options : []; + const optionIds = options + .map((item) => asRecord(item)?.optionId ?? asRecord(item)?.option_id) + .filter((value): value is string => typeof value === "string"); + + return { + title, + kind, + callId: + stringField(tool, "toolCallId") ?? + stringField(tool, "tool_call_id") ?? + stringField(rec ?? {}, "toolCallId") ?? + stringField(subject ?? {}, "toolCallId"), + preview, + optionIds, + }; +} + +/** + * Runtime-mode auto-answer for permission requests. `supervised` always asks; + * `auto-accept-edits` still asks for execute/other; looser modes auto-allow. + */ +export function autoPermissionOption( + runtimeMode: RuntimeMode, + kind: string | undefined, + optionIds: string[], +): string | null { + if (optionIds.length === 0) return null; + const tool = (kind ?? "").toLowerCase(); + if (runtimeMode === "supervised") return null; + if ( + runtimeMode === "auto-accept-edits" && + (tool === "execute" || tool === "other") + ) { + return null; + } + if (runtimeMode === "full-access") { + return pickOption(optionIds, [ + "allow-always", + "allow_always", + "allow-once", + "allow_once", + ]); + } + return pickOption(optionIds, [ + "allow-once", + "allow_once", + "allow-always", + "allow_always", + ]); +} + +export function permissionOptionId( + decision: ApprovalDecision, + optionIds: string[], +): string { + if (decision === "allow") { + return ( + pickOption(optionIds, [ + "allow-once", + "allow_once", + "allow-always", + "allow_always", + "allow", + ]) ?? "allow-once" + ); + } + return ( + pickOption(optionIds, [ + "reject-once", + "reject_once", + "reject-always", + "reject_always", + "reject", + "deny", + ]) ?? "reject-once" + ); +} + +export function sessionIdFromResult(result: unknown): string | undefined { + const rec = asRecord(result); + const id = rec?.sessionId ?? rec?.session_id ?? rec?.id; + return typeof id === "string" && id.trim() ? id.trim() : undefined; +} + +/** `session/new` carries the live catalog: models.availableModels + currentModelId. */ +export function modelsFromSessionNew(result: unknown): { + available: { modelId: string; name: string }[]; + current: string | undefined; +} { + const rec = asRecord(result); + const models = asRecord(rec?.models); + const available = Array.isArray(models?.availableModels) + ? models.availableModels.flatMap((item) => { + const entry = asRecord(item); + const modelId = String(entry?.modelId ?? "").trim(); + if (!modelId) return []; + return [{ modelId, name: String(entry?.name ?? modelId).trim() || modelId }]; + }) + : []; + const currentRaw = models?.currentModelId; + return { + available, + current: typeof currentRaw === "string" && currentRaw.trim() ? currentRaw.trim() : undefined, + }; +} + +/** + * The server's `available_commands_update` push: slash commands usable in + * prompts. Returns null for every other update so the caller's regular + * session/update routing is untouched. + */ +export function nativeCommandsFromUpdate( + params: unknown, +): { name: string; description: string }[] | null { + const update = asRecord(asRecord(params)?.update); + if (update?.sessionUpdate !== "available_commands_update") return null; + const list = Array.isArray(update.availableCommands) + ? update.availableCommands + : []; + return list.flatMap((value) => { + const row = asRecord(value); + const name = typeof row?.name === "string" ? row.name.trim() : ""; + if (!name || /[\s/\\]/.test(name)) return []; + return [ + { + name, + description: + typeof row?.description === "string" ? row.description : "", + }, + ]; + }); +} + +function planEvent(update: Record): HarnessEvent | null { + const entries = update.entries ?? update.plan; + if (Array.isArray(entries)) { + const items = entries.flatMap((item) => { + const rec = asRecord(item); + if (!rec) return []; + const content = String(rec.content ?? rec.text ?? rec.title ?? "").trim(); + if (!content) return []; + return [{ text: content, status: normalizeTaskListStatus(rec.status) }]; + }); + return { type: "tasks.updated", items }; + } + if (typeof update.text === "string" && update.text.trim()) { + return { type: "plan", text: update.text }; + } + return null; +} + +function usageFromUpdate(update: Record): HarnessEvent | null { + const usage = + asRecord(update.usage) ?? + asRecord(update.tokenUsage) ?? + asRecord(update.token_usage); + if (!usage) return null; + const used = + numberField(usage, "used") ?? + numberField(usage, "usedTokens") ?? + numberField(usage, "used_tokens"); + const window = + numberField(usage, "window") ?? + numberField(usage, "contextWindow") ?? + numberField(usage, "context_window"); + if (used == null && window == null) return null; + return { type: "context", used: used ?? undefined, window: window ?? undefined }; +} + +function mergePreview( + a: ToolPreview | undefined, + b: ToolPreview | undefined, +): ToolPreview | undefined { + if (!a) return b; + if (!b) return a; + return { ...b, ...a, path: a.path ?? b.path, query: a.query ?? b.query }; +} + +function toolLabel(rec: Record): string | undefined { + return ( + stringField(rec, "title") ?? + stringField(rec, "name") ?? + stringField(rec, "toolName") ?? + stringField(rec, "tool_name") + ); +} + +function toolDetail( + update: Record, + tool: Record, +): string | undefined { + const content = + textFromContent(update.content, "\n") || + textFromContent(tool.content, "\n"); + if (content.trim()) return cap(content); + const output = update.rawOutput ?? tool.rawOutput; + if (typeof output === "string" && output.trim()) return cap(output); + const outputText = textFromContent(output); + return outputText.trim() ? cap(outputText) : undefined; +} + +function cap(value: string, max = 8_000): string { + const text = value.trim(); + if (text.length <= max) return text; + return `${text.slice(0, max)}\n…`; +} + +function pickOption(optionIds: string[], preferred: string[]): string | null { + for (const id of preferred) { + if (optionIds.includes(id)) return id; + } + return null; +} + +function textFromContent(content: unknown, separator = ""): string { + if (typeof content === "string") return content; + const rec = asRecord(content); + if (rec && typeof rec.text === "string") return rec.text; + if (rec && rec.content != null) return textFromContent(rec.content, separator); + if (Array.isArray(content)) { + return content + .map((item) => textFromContent(item, separator)) + .filter(Boolean) + .join(separator); + } + return ""; +} + +export function asRecord(value: unknown): Record | null { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + return null; +} + +export function stringField( + rec: Record, + key: string, +): string | undefined { + const value = rec[key]; + return typeof value === "string" && value.trim() ? value : undefined; +} + +function numberField( + rec: Record, + key: string, +): number | undefined { + const value = rec[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} diff --git a/src/lib/harness/opencrabsText.test.ts b/src/lib/harness/opencrabsText.test.ts new file mode 100644 index 00000000..70872f78 --- /dev/null +++ b/src/lib/harness/opencrabsText.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const spawned: Array<{ command: string; args: string[]; cwd: string }> = []; +let onLine: ((line: string) => void) | undefined; +let onClose: (() => void) | undefined; + +vi.mock("./child", () => ({ + resolveOpenCrabsBinary: async () => ({ path: "/fake/opencrabs" }), + spawnChild: async (_id: string, command: string, args: string[], cwd: string) => { + spawned.push({ command, args, cwd }); + }, + killChild: async () => undefined, + unwatchChild: () => undefined, + watchChild: ( + _id: string, + line: (l: string) => void, + close: () => void, + ) => { + onLine = line; + onClose = close; + }, +})); + +import { parseRunSummary, runOpenCrabsTextPrompt } from "./opencrabsText"; + +describe("parseRunSummary", () => { + it("reads the content field of the final JSON object", () => { + const stdout = [ + "🤔 Processing...", + "TITLE-OK", + "{", + ' "content": "TITLE-OK",', + ' "cost": 0.01,', + ' "session_id": "abc"', + "}", + ].join("\n"); + expect(parseRunSummary(stdout)).toBe("TITLE-OK"); + }); + + it("skips brace lines that are not the summary object", () => { + const stdout = '{"noise": true}\nplain answer\n{"content": "real"}'; + expect(parseRunSummary(stdout)).toBe("real"); + }); + + it("returns null when no summary object is present", () => { + expect(parseRunSummary("just plain text\nmore text")).toBeNull(); + }); +}); + +describe("runOpenCrabsTextPrompt", () => { + beforeEach(() => { + spawned.length = 0; + onLine = undefined; + onClose = undefined; + }); + + it("spawns opencrabs run with json format and returns the content", async () => { + const pending = runOpenCrabsTextPrompt({ + cwd: "/repo", + prompt: "title this", + }); + await vi.waitFor(() => { + expect(spawned).toHaveLength(1); + }); + expect(spawned[0]).toEqual({ + command: "/fake/opencrabs", + args: ["run", "--quiet", "--format", "json", "title this"], + cwd: "/repo", + }); + onLine?.("🤔 Processing..."); + onLine?.('{"content": "My Title"}'); + onClose?.(); + await expect(pending).resolves.toBe("My Title"); + }); + + it("rejects when the run produces no summary object", async () => { + const pending = runOpenCrabsTextPrompt({ cwd: "/repo", prompt: "hi" }); + await vi.waitFor(() => { + expect(spawned).toHaveLength(1); + }); + onLine?.("everything broke"); + onClose?.(); + await expect(pending).rejects.toThrow(/everything broke/); + }); +}); diff --git a/src/lib/harness/opencrabsText.ts b/src/lib/harness/opencrabsText.ts new file mode 100644 index 00000000..ebbc8b85 --- /dev/null +++ b/src/lib/harness/opencrabsText.ts @@ -0,0 +1,115 @@ +import { + killChild, + resolveOpenCrabsBinary, + spawnChild, + unwatchChild, + watchChild, +} from "./child"; + +const TEXT_CHILD_ID = "monocode-opencrabs-text"; +const REQUEST_TIMEOUT_MS = 45_000; + +/** + * One-shot text generation through `opencrabs run --quiet --format json`. + * + * Unlike the REPL/server harnesses there is nothing to keep warm: each + * prompt spawns a short-lived child, captures stdout until exit, and reads + * the structured summary object. `--quiet` (parity branch and later) keeps + * stdout payload-only; the parser below still scans backwards for the final + * parseable object with a string `content` field, so progress noise from a + * binary without the flag degrades gracefully instead of corrupting output. + * Calls are serialized because each one carries the full headless context. + */ +let turns: Promise = Promise.resolve(); + +export async function runOpenCrabsTextPrompt(input: { + cwd: string; + prompt: string; + timeoutMs?: number; +}): Promise { + const run = turns.catch(() => undefined).then(() => promptOnce(input)); + turns = run.then( + () => undefined, + () => undefined, + ); + return run; +} + +async function promptOnce(input: { + cwd: string; + prompt: string; + timeoutMs?: number; +}): Promise { + const { path } = await resolveOpenCrabsBinary(); + const timeoutMs = input.timeoutMs ?? REQUEST_TIMEOUT_MS; + let stdout = ""; + let exited = false; + let notifyExit: () => void = () => undefined; + const exitPromise = new Promise((resolve) => { + notifyExit = resolve; + }); + + watchChild( + TEXT_CHILD_ID, + (line) => { + stdout += `${line}\n`; + }, + () => { + exited = true; + notifyExit(); + }, + ); + + const timer = setTimeout(() => { + if (!exited) void killChild(TEXT_CHILD_ID); + }, timeoutMs); + + try { + await spawnChild( + TEXT_CHILD_ID, + path, + ["run", "--quiet", "--format", "json", input.prompt], + input.cwd, + ); + await exitPromise; + } finally { + clearTimeout(timer); + unwatchChild(TEXT_CHILD_ID); + if (!exited) await killChild(TEXT_CHILD_ID).catch(() => undefined); + } + + const text = parseRunSummary(stdout); + if (!text) { + const nonEmpty = stdout.trim().split("\n").filter(Boolean); + const tail = nonEmpty[nonEmpty.length - 1] ?? ""; + throw new Error( + tail + ? `OpenCrabs text generation failed: ${tail.slice(0, 200)}` + : "OpenCrabs returned empty output.", + ); + } + return text; +} + +/** Last JSON object on stdout with a string `content` field, if any. */ +export function parseRunSummary(stdout: string): string | null { + const lines = stdout.split("\n"); + for (let i = lines.length - 1; i >= 0; i--) { + if (!lines[i].startsWith("{")) continue; + try { + const parsed: unknown = JSON.parse(lines.slice(i).join("\n")); + if ( + parsed && + typeof parsed === "object" && + "content" in parsed && + typeof (parsed as { content: unknown }).content === "string" + ) { + const content = (parsed as { content: string }).content.trim(); + if (content) return content; + } + } catch { + // Not the summary object; keep scanning upwards. + } + } + return null; +} diff --git a/src/lib/harness/opencrabsTitle.ts b/src/lib/harness/opencrabsTitle.ts new file mode 100644 index 00000000..0d52ba49 --- /dev/null +++ b/src/lib/harness/opencrabsTitle.ts @@ -0,0 +1,26 @@ +import { + buildThreadTitlePrompt, + parseGeneratedSessionTitle, + type GeneratedSessionTitle, +} from "../sessionTitle"; +import { runOpenCrabsTextPrompt } from "./opencrabsText"; + +const TITLE_TIMEOUT_MS = 45_000; + +export async function generateOpenCrabsSessionTitle(input: { + sessionId: string; + cwd: string; + message: string; +}): Promise { + try { + const output = await runOpenCrabsTextPrompt({ + cwd: input.cwd, + prompt: buildThreadTitlePrompt(input.message), + timeoutMs: TITLE_TIMEOUT_MS, + }); + return parseGeneratedSessionTitle(output, input.message); + } catch (error) { + console.debug("[monocode] opencrabs session title", error); + return null; + } +} diff --git a/src/lib/harness/register.ts b/src/lib/harness/register.ts index c8fbfe86..2f619f56 100644 --- a/src/lib/harness/register.ts +++ b/src/lib/harness/register.ts @@ -5,6 +5,7 @@ import { ensureFxRegistered } from "./fxAdapter"; import { ensureGrokRegistered } from "./grokAdapter"; import { ensureOpenCodeRegistered } from "./opencodeAdapter"; import { ensureOmpRegistered } from "./ompAdapter"; +import { ensureOpenCrabsRegistered } from "./opencrabsAdapter"; import { ensurePiRegistered } from "./piAdapter"; /** Register all known live harness adapters. Idempotent. */ @@ -17,4 +18,5 @@ export function registerBuiltinHarnesses(): void { ensurePiRegistered(); ensureOmpRegistered(); ensureFxRegistered(); + ensureOpenCrabsRegistered(); } diff --git a/src/lib/harness/registry.test.ts b/src/lib/harness/registry.test.ts index 9ae847ae..8388192f 100644 --- a/src/lib/harness/registry.test.ts +++ b/src/lib/harness/registry.test.ts @@ -97,6 +97,7 @@ describe("harness registry", () => { "pi", "omp", "fx", + "opencrabs", ]; expect( @@ -110,6 +111,7 @@ describe("harness registry", () => { pi: true, omp: true, fx: false, + opencrabs: true, }); }); diff --git a/src/lib/harness/textHarness.ts b/src/lib/harness/textHarness.ts index a50e9107..f8427b10 100644 --- a/src/lib/harness/textHarness.ts +++ b/src/lib/harness/textHarness.ts @@ -13,6 +13,7 @@ const TEXT_HARNESSES: HarnessId[] = [ "codex", "grok", "opencode", + "opencrabs", ]; /** Pick the harness used for titles, commit messages, and PR text. */ diff --git a/src/lib/models.ts b/src/lib/models.ts index 91a0c1a0..e7b90198 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -170,6 +170,12 @@ export const MODELS: AgentModel[] = [ name: "GLM 5.2 Fast", nativeId: "zai/glm-5.2-fast", }, + { + id: "opencrabs:default", + harness: "opencrabs", + name: "Default", + nativeId: "", + }, ]; export const DEFAULT_MODEL_ID: Record = { @@ -181,6 +187,7 @@ export const DEFAULT_MODEL_ID: Record = { pi: "pi:default", omp: "omp:default", fx: "fx:zai/glm-5.2-fast", + opencrabs: "opencrabs:default", }; const FAVORITES_KEY = "monocode.favoriteModels"; @@ -208,6 +215,7 @@ const HARNESS_ORDER: HarnessId[] = [ "pi", "omp", "fx", + "opencrabs", ]; const EMPTY_MODELS: AgentModel[] = []; diff --git a/src/lib/session.ts b/src/lib/session.ts index 5477fabf..311c7533 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -13,7 +13,7 @@ import { } from "./models"; export type HarnessId = - "claude" | "codex" | "cursor" | "grok" | "opencode" | "pi" | "omp" | "fx"; + "claude" | "codex" | "cursor" | "grok" | "opencode" | "pi" | "omp" | "fx" | "opencrabs"; export const HARNESSES: HarnessId[] = [ "claude", @@ -24,6 +24,7 @@ export const HARNESSES: HarnessId[] = [ "pi", "omp", "fx", + "opencrabs", ]; export type BlockRole = @@ -345,6 +346,7 @@ export const HARNESS_LABEL: Record = { pi: "pi", omp: "omp", fx: "fx", + opencrabs: "opencrabs", }; export const HARNESS_TITLE: Record = { @@ -356,6 +358,7 @@ export const HARNESS_TITLE: Record = { pi: "Pi", omp: "omp", fx: "fx", + opencrabs: "OpenCrabs", }; /** fx ACP rejects attachment prompt blocks. */