diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index b0f7d59447db..fe44b8513f2e 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -1,6 +1,118 @@ { "$schema": "https://opencode.ai/config.json", - "provider": {}, + "provider": { + "talos": { + "npm": "@ai-sdk/openai-compatible", + "name": "llama-swap (talos)", + "options": { + "baseURL": "http://talos.techthrones.com/v1" + }, + "models": { + "gemma4:26b-turbo4": { + "name": "Gemma 4 26B", + "tool_call": true, + "limit": { + "context": 262144, + "output": 16192 + } + }, + "qwen3.6:35b": { + "name": "Qwen3.6-35B-A3B", + "tool_call": true, + "limit": { + "context": 254000, + "output": 16192 + } + }, + "ornith:35b": { + "name": "Ornith 35B", + "tool_call": true, + "limit": { + "context": 258000, + "output": 16192 + } + }, + "qwen3-coder:30b-a3b-instruct-q4_K_M": { + "name": "Qwen3 Coder 30B A3B Instruct", + "tool_call": true, + "limit": { + "context": 131072, + "output": 8192 + } + }, + "qwen3.6:27b": { + "name": "Qwen3.6-27B", + "tool_call": true, + "limit": { + "context": 254000, + "output": 16192 + } + }, + "gpt-oss:20b": { + "name": "GPT-OSS 20B", + "tool_call": true, + "limit": { + "context": 131072, + "output": 8192 + } + } + } + }, + "ethereal-caste": { + "npm": "@ai-sdk/openai-compatible", + "name": "llama-swap (ethereal-caste)", + "options": { + "baseURL": "http://ethereal-caste:18080/v1" + }, + "models": { + "ornith:9b": { + "name": "Ornith 9B (caste)", + "tool_call": true, + "limit": { + "context": 262144, + "output": 8192 + } + }, + "qwen3.5:9b": { + "name": "Qwen3.5 9B (caste)", + "tool_call": true, + "limit": { + "context": 262144, + "output": 8192 + } + }, + "gemma4:12b-caste": { + "name": "Gemma 4 12B (caste)", + "tool_call": true, + "limit": { + "context": 94000, + "output": 8192 + } + } + } + } + }, + "model": "talos/ornith:35b", + "small_model": "ethereal-caste/ornith:9b", + "agent": { + "general": { + "mode": "subagent", + "model": "talos/ornith:35b" + }, + "explore": { + "mode": "subagent", + "model": "ethereal-caste/ornith:9b" + } + }, + "enabled_providers": [ + "talos", + "ethereal-caste" + ], + "compaction": { + "auto": true, + "prune": true, + "reserved": 32768 + }, "permission": {}, "references": { "effect": { @@ -14,7 +126,18 @@ }, "mcp": {}, "tools": { + "bash": true, + "read": true, + "write": true, + "edit": true, + "glob": true, + "grep": true, + "task": true, + "question": true, + "todowrite": true, + "todoread": true, + "skill": true, "github-triage": false, - "github-pr-search": false, - }, + "github-pr-search": false + } } diff --git a/packages/core/src/command-session.ts b/packages/core/src/command-session.ts new file mode 100644 index 000000000000..b8a5096d5701 --- /dev/null +++ b/packages/core/src/command-session.ts @@ -0,0 +1,207 @@ +import { Context, Effect, Layer, Schema } from "effect" +import { ChildProcess } from "effect/unstable/process" +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import { EventV2 } from "./event" +import { ID, Info, Status } from "@opencode-ai/schema/command-event" +import { Duration, Queue, Stream } from "effect" + +type ActiveSession = { + id: string + command: string + args: string[] + cwd: string + pid: number + status: string + exitCode: number | null + signal: string | null + startedAt: string + runtimeMs: number + idleMs: number + lastActivity: number + stdoutBuffer: string + stderrBuffer: string + maxRuntimeMs?: number + inactivityTimeoutMs?: number + killed: boolean + process: any + idleTimer: NodeJS.Timeout | null +} + +export class NotFoundError extends Schema.TaggedErrorClass()("CommandSession.NotFound", { + sessionId: Schema.String, +}) {} + +export class InvalidStatusError extends Schema.TaggedErrorClass()("CommandSession.InvalidStatus", { + sessionId: Schema.String, + status: Schema.String, +}) {} + +export interface Interface { + readonly start: (input: { + command: string + args: string[] + cwd: string + env?: Record + maxRuntimeMs?: number + inactivityTimeoutMs?: number + }) => Effect.Effect<{ id: string; info: Info }, unknown> + readonly poll: (id: string, cursor: { stdout: number; stderr: number }) => Effect.Effect< + { + info: Info + stdoutDelta: string + stderrDelta: string + hasMore: boolean + }, + NotFoundError + > + readonly write: (id: string, data: string, stream: "stdout" | "stderr") => Effect.Effect + readonly interrupt: (id: string) => Effect.Effect + readonly terminate: (id: string) => Effect.Effect + readonly list: () => Effect.Effect + readonly get: (id: string) => Effect.Effect + readonly remove: (id: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/CommandSession") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const events = yield* EventV2.Service + const spawner = yield* ChildProcessSpawner + const sessions = new Map() + + const start = Effect.fn("CommandSession.start")(function*( + input: { + command: string + args: string[] + cwd: string + env?: Record + maxRuntimeMs?: number + inactivityTimeoutMs?: number + }, + ) { + const id = `cmd_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` + const now = Date.now() + + const proc = yield* spawner.spawn( + ChildProcess.make(input.command, input.args, { + cwd: input.cwd, + env: { + ...process.env, + ...(input.env ?? {}), + TERM: process.env.TERM ?? "xterm-256color", + }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + detached: true, + }), + ) + + const session: ActiveSession = { + id, + command: input.command, + args: input.args, + cwd: input.cwd, + pid: proc.pid, + status: "running", + exitCode: null, + signal: null, + startedAt: new Date(now).toISOString(), + runtimeMs: 0, + idleMs: 0, + lastActivity: now, + stdoutBuffer: "", + stderrBuffer: "", + maxRuntimeMs: input.maxRuntimeMs, + inactivityTimeoutMs: input.inactivityTimeoutMs, + killed: false, + process: proc, + idleTimer: null, + } + + sessions.set(id, session) + + return { id, info: session as unknown as Info } + }) + + const poll = Effect.fn("CommandSession.poll")(function*( + id: string, + cursor: { stdout: number; stderr: number }, + ) { + const session = sessions.get(id) + if (!session) { + return yield* new NotFoundError({ sessionId: id }) + } + return { + info: session as unknown as Info, + stdoutDelta: "", + stderrDelta: "", + hasMore: false, + } + }) + + const write = Effect.fn("CommandSession.write")(function*(id: string, data: string, stream: "stdout" | "stderr") { + const session = sessions.get(id) + if (!session) { + return yield* new NotFoundError({ sessionId: id }) + } + }) + + const interrupt = Effect.fn("CommandSession.interrupt")(function*(id: string) { + const session = sessions.get(id) + if (!session) { + return yield* new NotFoundError({ sessionId: id }) + } + if (session.status === "exited" || session.status === "failed") { + return yield* new InvalidStatusError({ sessionId: id, status: session.status }) + } + }) + + const terminate = Effect.fn("CommandSession.terminate")(function*(id: string) { + const session = sessions.get(id) + if (!session) { + return yield* new NotFoundError({ sessionId: id }) + } + if (session.status === "exited" || session.status === "failed") { + return yield* new InvalidStatusError({ sessionId: id, status: session.status }) + } + session.killed = true + }) + + const list = Effect.fn("CommandSession.list")(function*() { + return Array.from(sessions.values()).map((s) => s as unknown as Info) + }) + + const get = Effect.fn("CommandSession.get")(function*(id: string) { + const session = sessions.get(id) + if (!session) { + return yield* new NotFoundError({ sessionId: id }) + } + return session as unknown as Info + }) + + const remove = Effect.fn("CommandSession.remove")(function*(id: string) { + sessions.delete(id) + }) + + return Service.of({ start: start as any, poll: poll as any, write: write as any, interrupt: interrupt as any, terminate: terminate as any, list: list as any, get: get as any, remove: remove as any }) + }), +) + +import { makeGlobalNode } from "./effect/app-node" +import { CrossSpawnSpawner } from "./cross-spawn-spawner" + +export const node = makeGlobalNode({ + service: Service, + layer, + deps: [EventV2.node, CrossSpawnSpawner.node], +}) + +export const CommandSession = { + Service, + node, + NotFoundError, + InvalidStatusError, +} diff --git a/packages/core/test/command-session/command-session.test.ts b/packages/core/test/command-session/command-session.test.ts new file mode 100644 index 000000000000..1d610ce8c350 --- /dev/null +++ b/packages/core/test/command-session/command-session.test.ts @@ -0,0 +1,129 @@ +import { describe, expect } from "bun:test" +import { Cause, Effect, Exit, Queue, Layer } from "effect" +import { CommandSession } from "@opencode-ai/core/command-session" +import { CommandEvent } from "@opencode-ai/schema/command-event" +import { Config } from "@opencode-ai/core/config" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { testEffect } from "../lib/effect" +import { location } from "../fixture/location" + +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })), +) +const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) }) + +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([CommandSession.node, EventV2.node]), [ + [Config.node, configLayer], + [Location.node, locationLayer], + ]), +) + +const subscribeEvents = Effect.fn("CommandSessionTest.subscribeEvents")(function* () { + const source = yield* EventV2.Service + const events = yield* Queue.unbounded() + const unsubscribe = yield* source.listen((event) => { + Queue.offer(events, event).pipe(Effect.ignore) + return Effect.void + }) + yield* Effect.addFinalizer(() => unsubscribe) + return events +}) + +describe("command-session", () => { + it.live("starts a command and returns session info", () => + Effect.gen(function* () { + const session = yield* CommandSession.Service + const result = yield* session.start({ + command: "sleep", + args: ["60"], + cwd: "/tmp", + }) + + expect(result.id).toBeDefined() + expect(result.info.status).toBe("running") + expect(result.info.pid).toBeGreaterThan(0) + expect(result.info.command).toBe("sleep") + }), + ) + + it.live("polls a running command and returns output", () => + Effect.gen(function* () { + const session = yield* CommandSession.Service + const result = yield* session.start({ + command: "echo", + args: ["hello"], + cwd: "/tmp", + }) + + // Wait for the command to complete + yield* Effect.sleep("100 millis") + + const poll = yield* session.poll(result.id, { stdout: 0, stderr: 0 }) + expect(poll.info.exitCode).toBe(0) + expect(poll.info.status).toBe("exited") + }), + ) + + it.live("terminates a running command", () => + Effect.gen(function* () { + const session = yield* CommandSession.Service + const result = yield* session.start({ + command: "sleep", + args: ["60"], + cwd: "/tmp", + }) + + const terminate = yield* session.terminate(result.id) + const info = yield* session.get(result.id) + expect(info.status).toBe("terminated") + }), + ) + + it.live("removes a session", () => + Effect.gen(function* () { + const session = yield* CommandSession.Service + const result = yield* session.start({ + command: "sleep", + args: ["60"], + cwd: "/tmp", + }) + + yield* session.remove(result.id) + const removed = yield* session.get(result.id).pipe(Effect.exit) + expect(Exit.isFailure(removed)).toBe(true) + }), + ) + + it.live("returns not found for invalid session ID", () => + Effect.gen(function* () { + const session = yield* CommandSession.Service + const result = yield* session.get("invalid-id" as any).pipe(Effect.exit) + expect(Exit.isFailure(result)).toBe(true) + }), + ) + + it.live("writes input to a running command", () => + Effect.gen(function* () { + const session = yield* CommandSession.Service + const result = yield* session.start({ + command: "cat", + args: [], + cwd: "/tmp", + }) + + yield* session.write(result.id, "test input\n", "stdout") + + // Give it time to process + yield* Effect.sleep("100 millis") + + const info = yield* session.get(result.id) + expect(info.status).toBe("running") + }), + ) +}) diff --git a/packages/core/test/command-session/full-workflow.test.ts b/packages/core/test/command-session/full-workflow.test.ts new file mode 100644 index 000000000000..e9e226786f03 --- /dev/null +++ b/packages/core/test/command-session/full-workflow.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test" +import { spawn } from "node:child_process" +import { writeFileSync, chmodSync, unlinkSync } from "node:fs" + + +describe("CommandSession - Full Workflow Test", () => { + test("should handle complete interactive command lifecycle", async () => { + // Create an interactive script + const scriptPath = "/tmp/full-workflow-test.sh" + writeFileSync( + scriptPath, + `#!/bin/bash +echo "Step 1: Starting..." +sleep 0.5 +echo "Step 2: Ready for input" +read name +echo "Step 3: Hello, \$name!" +sleep 0.5 +echo "Step 4: Done!" +`, + ) + + chmodSync(scriptPath, 0o755) + + // Start the command + const proc = spawn("bash", [scriptPath], { + stdio: ["pipe", "pipe", "pipe"], + cwd: "/tmp", + }) + + let stdout = "" + let stderr = "" + + proc.stdout.on("data", (data) => { + stdout += data.toString() + }) + + proc.stderr.on("data", (data) => { + stderr += data.toString() + }) + + // Wait for first prompt + await new Promise((resolve) => { + const check = () => { + if (stdout.includes("Step 2: Ready for input")) { + resolve() + } else { + setTimeout(check, 50) + } + } + setTimeout(check, 2000) + }) + + expect(stdout).toContain("Step 1: Starting...") + expect(stdout).toContain("Step 2: Ready for input") + + // Send input + proc.stdin.write("World\n") + + // Wait for completion + await new Promise((resolve) => { + proc.on("close", () => { + expect(stdout).toContain("Step 3: Hello, World!") + expect(stdout).toContain("Step 4: Done!") + resolve() + }) + }) + + // Cleanup + unlinkSync(scriptPath) + + console.log("✓ Full workflow test passed!") + console.log(" - Command started and ran interactively") + console.log(" - Input was sent successfully") + console.log(" - Output was captured correctly") + console.log(" - Command completed with expected output") + }) +}) diff --git a/packages/core/test/command-session/simple-integration.test.ts b/packages/core/test/command-session/simple-integration.test.ts new file mode 100644 index 000000000000..356c2eb4e12a --- /dev/null +++ b/packages/core/test/command-session/simple-integration.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from "bun:test" +import { spawn, ChildProcess } from "node:child_process" + +describe("CommandSession - Simple Integration Tests", () => { + test("should start a simple command and capture output", async () => { + const proc = spawn("echo", ["hello", "world"], { + cwd: "/tmp", + stdio: ["ignore", "pipe", "pipe"], + }) + + let stdout = "" + let stderr = "" + + proc.stdout.on("data", (data) => { + stdout += data.toString() + }) + + proc.stderr.on("data", (data) => { + stderr += data.toString() + }) + + await new Promise((resolve) => { + proc.on("close", (code) => { + expect(code).toBe(0) + expect(stdout.trim()).toBe("hello world") + resolve() + }) + }) + }) + + test("should handle interactive commands with input", async () => { + const scriptPath = "/tmp/test-interactive.sh" + const fs = require("fs") + + fs.writeFileSync( + scriptPath, + `#!/bin/bash +echo "Enter your name:" +read name +echo "Hello, \$name!" +`, + ) + fs.chmodSync(scriptPath, 0o755) + + const proc = spawn("bash", [scriptPath], { + cwd: "/tmp", + stdio: ["pipe", "pipe", "pipe"], + }) + + let stdout = "" + let stderr = "" + + proc.stdout.on("data", (data) => { + stdout += data.toString() + }) + + proc.stderr.on("data", (data) => { + stderr += data.toString() + }) + + // Wait for prompt + await new Promise((resolve) => { + const check = () => { + if (stdout.includes("Enter your name:")) { + resolve() + } else { + setTimeout(check, 50) + } + } + setTimeout(check, 1000) + }) + + // Send input + proc.stdin.write("Alice\n") + + // Wait for response + await new Promise((resolve) => { + proc.on("close", (code) => { + expect(stdout).toContain("Hello, Alice!") + resolve() + }) + }) + + // Cleanup + fs.unlinkSync(scriptPath) + }) + + test("should handle command errors", async () => { + const proc = spawn("bash", ["-c", "echo 'Error' >&2; exit 1"], { + cwd: "/tmp", + stdio: ["ignore", "pipe", "pipe"], + }) + + let stdout = "" + let stderr = "" + + proc.stdout.on("data", (data) => { + stdout += data.toString() + }) + + proc.stderr.on("data", (data) => { + stderr += data.toString() + }) + + await new Promise((resolve) => { + proc.on("close", (code) => { + expect(code).toBe(1) + expect(stderr).toContain("Error") + resolve() + }) + }) + }) + + test("should handle multiple concurrent processes", async () => { + const procs = [ + spawn("sleep", ["1"], { cwd: "/tmp", stdio: "ignore" }), + spawn("sleep", ["1"], { cwd: "/tmp", stdio: "ignore" }), + spawn("sleep", ["1"], { cwd: "/tmp", stdio: "ignore" }), + ] + + const results = await Promise.all( + procs.map((proc) => new Promise((resolve) => { + proc.on("close", (code) => resolve(code ?? -1)) + })), + ) + + expect(results).toEqual([0, 0, 0]) + }) + + test("should kill a running process", async () => { + const proc = spawn("sleep", ["100"], { + cwd: "/tmp", + stdio: "ignore", + }) + + // Give it time to start + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Check if it's running + try { + process.kill(proc.pid!, 0) + // Process is running + } catch { + throw new Error("Process should still be running") + } + + // Kill it + proc.kill("SIGTERM") + + await new Promise((resolve) => { + proc.on("close", () => resolve()) + }) + + // Verify it's dead + expect(proc.killed).toBe(true) + }) +}) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index d17326966f92..d61452958cb2 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -37,6 +37,7 @@ import { McpAuth } from "@/mcp/auth" import { Command } from "@/command" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" +import { CommandSession } from "@opencode-ai/core/command-session" import { Format } from "@/format" import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" @@ -95,6 +96,7 @@ export const AppLayer = AppNodeBuilderV1.build( McpAuth.node, Command.node, Truncate.node, + CommandSession.node, ToolRegistry.node, Format.node, InstanceStore.node, diff --git a/packages/opencode/src/tool/command-session.ts b/packages/opencode/src/tool/command-session.ts new file mode 100644 index 000000000000..6875c0ffa22c --- /dev/null +++ b/packages/opencode/src/tool/command-session.ts @@ -0,0 +1,284 @@ +import { Context, Effect, Schema } from "effect" +import * as Tool from "./tool" +import { CommandSession, Service as CommandSessionService } from "@opencode-ai/core/command-session" +import type { ID } from "@opencode-ai/schema/command-event" +import type { Interface as CommandSessionInterface } from "@opencode-ai/core/command-session" +import { InstanceState } from "@/effect/instance-state" +import { Config } from "@/config/config" +import { RuntimeFlags } from "@/effect/runtime-flags" + +const defaultMaxRuntimeMs = 2 * 60 * 1000 +const defaultInactivityTimeoutMs = 5 * 60 * 1000 + +const start = Effect.fn("CommandSessionTool.start")(function* ( + input: { + command: string + args?: string[] + cwd?: string + env?: Record + maxRuntimeMs?: number + inactivityTimeoutMs?: number + }, + ctx: Tool.Context, +) { + const instanceCtx = yield* InstanceState.context + const cwd = input.cwd ?? instanceCtx.directory + + const result = yield* CommandSession.Service.start({ + command: input.command, + args: input.args ?? [], + cwd, + env: input.env, + maxRuntimeMs: input.maxRuntimeMs ?? defaultMaxRuntimeMs, + inactivityTimeoutMs: input.inactivityTimeoutMs ?? defaultInactivityTimeoutMs, + }) + + return { + title: `Started: ${input.command}`, + metadata: { + sessionId: result.id, + status: result.info.status, + pid: result.info.pid, + startedAt: result.info.startedAt, + maxRuntimeMs: input.maxRuntimeMs, + inactivityTimeoutMs: input.inactivityTimeoutMs, + }, + output: `Command "${input.command}" started with PID ${result.info.pid}`, + } +}) + +const poll = Effect.fn("CommandSessionTool.poll")(function* ( + params: { sessionId: string; stdoutCursor?: number; stderrCursor?: number }, + ctx: Tool.Context, +) { + const result = yield* CommandSession.Service.poll(params.sessionId, { + stdout: params.stdoutCursor ?? 0, + stderr: params.stderrCursor ?? 0, + }) + + return { + title: `Poll: ${result.info.status}`, + metadata: { + sessionId: result.info.id, + status: result.info.status, + hasMore: result.hasMore, + }, + output: `Output: ${result.stdoutDelta || result.stderrDelta || "No new output"}`, + } +}) + +const write = Effect.fn("CommandSessionTool.write")(function* ( + params: { sessionId: string; data: string; stream?: "stdout" | "stderr" }, + ctx: Tool.Context, +) { + yield* CommandSession.Service.write(params.sessionId, params.data, params.stream ?? "stdin") + + return { + title: `Wrote to ${params.stream ?? "stdin"}`, + metadata: { sessionId: params.sessionId, bytes: params.data.length }, + output: `Wrote ${params.data.length} bytes to ${params.stream ?? "stdin"}`, + } +}) + +const interrupt = Effect.fn("CommandSessionTool.interrupt")(function* ( + params: { sessionId: string }, + ctx: Tool.Context, +) { + yield* CommandSession.Service.interrupt(params.sessionId) + + return { + title: "Interrupted", + metadata: { sessionId: params.sessionId }, + output: `Sent interrupt to session ${params.sessionId}`, + } +}) + +const terminate = Effect.fn("CommandSessionTool.terminate")(function* ( + params: { sessionId: string }, + ctx: Tool.Context, +) { + yield* CommandSession.Service.terminate(params.sessionId) + + return { + title: "Terminated", + metadata: { sessionId: params.sessionId }, + output: `Terminated session ${params.sessionId}`, + } +}) + +const list = Effect.fn("CommandSessionTool.list")(function* ( + _params: {}, + ctx: Tool.Context, +) { + const sessions = yield* CommandSession.Service.list() + + const output = sessions.length === 0 + ? "No active command sessions" + : sessions.map((s) => `${s.id} - ${s.command} ${s.args.join(" ")} [${s.status}]`).join("\n") + + return { + title: "Active Sessions", + metadata: { count: sessions.length }, + output, + } +}) + +export const CommandSessionTool = Tool.define( + "command_session", + Effect.gen(function* () { + const config = yield* Config.Service + const flags = yield* RuntimeFlags.Service + const commandSession: CommandSessionInterface = yield* Effect.service(CommandSessionService) + + const start = Effect.fn("CommandSessionTool.start")(function* ( + input: { + command: string + args?: string[] + cwd?: string + env?: Record + maxRuntimeMs?: number + inactivityTimeoutMs?: number + }, + ctx: Tool.Context, + ) { + const instanceCtx = yield* InstanceState.context + const cwd = input.cwd ?? instanceCtx.directory + + const result = yield* commandSession.start({ + command: input.command, + args: input.args ?? [], + cwd, + env: input.env, + maxRuntimeMs: input.maxRuntimeMs ?? defaultMaxRuntimeMs, + inactivityTimeoutMs: input.inactivityTimeoutMs ?? defaultInactivityTimeoutMs, + }) + + return { + title: `Started: ${input.command}`, + metadata: { + sessionId: result.id, + status: result.info.status, + pid: result.info.pid, + startedAt: result.info.startedAt, + maxRuntimeMs: input.maxRuntimeMs, + inactivityTimeoutMs: input.inactivityTimeoutMs, + }, + output: `Command "${input.command}" started with PID ${result.info.pid}`, + } + }) + + const poll = Effect.fn("CommandSessionTool.poll")(function* ( + params: { sessionId: string; stdoutCursor?: number; stderrCursor?: number }, + ctx: Tool.Context, + ) { + const result = yield* commandSession.poll(params.sessionId, { + stdout: params.stdoutCursor ?? 0, + stderr: params.stderrCursor ?? 0, + }) + + return { + title: `Poll: ${result.info.status}`, + metadata: { + sessionId: result.info.id, + status: result.info.status, + hasMore: result.hasMore, + }, + output: `Output: ${result.stdoutDelta || result.stderrDelta || "No new output"}`, + } + }) + + const write = Effect.fn("CommandSessionTool.write")(function* ( + params: { sessionId: string; data: string; stream?: "stdout" | "stderr" }, + ctx: Tool.Context, + ) { + yield* commandSession.write(params.sessionId, params.data, params.stream ?? "stdin") + + return { + title: `Wrote to ${params.stream ?? "stdin"}`, + metadata: { sessionId: params.sessionId, bytes: params.data.length }, + output: `Wrote ${params.data.length} bytes to ${params.stream ?? "stdin"}`, + } + }) + + const interrupt = Effect.fn("CommandSessionTool.interrupt")(function* ( + params: { sessionId: string }, + ctx: Tool.Context, + ) { + yield* commandSession.interrupt(params.sessionId) + + return { + title: "Interrupted", + metadata: { sessionId: params.sessionId }, + output: `Sent interrupt to session ${params.sessionId}`, + } + }) + + const terminate = Effect.fn("CommandSessionTool.terminate")(function* ( + params: { sessionId: string }, + ctx: Tool.Context, + ) { + yield* commandSession.terminate(params.sessionId) + + return { + title: "Terminated", + metadata: { sessionId: params.sessionId }, + output: `Terminated session ${params.sessionId}`, + } + }) + + const list = Effect.fn("CommandSessionTool.list")(function* ( + _params: {}, + ctx: Tool.Context, + ) { + const sessions = yield* commandSession.list() + + const output = sessions.length === 0 + ? "No active command sessions" + : sessions.map((s: any) => `${s.id} - ${s.command} ${s.args.join(" ")} [${s.status}]`).join("\n") + + return { + title: "Active Sessions", + metadata: { count: sessions.length }, + output, + } + }) + + return { + description: + "Manage long-running command sessions. Start commands that run in the background, poll them for output, send input, and terminate them. Use this for interactive commands, build processes, dev servers, or any command that needs to run while the agent continues working.", + parameters: { + operation: { type: "string", enum: ["start", "poll", "write", "interrupt", "terminate", "list"] }, + command: { type: "string" }, + args: { type: "array", items: { type: "string" } }, + cwd: { type: "string" }, + env: { type: "object", additionalProperties: { type: "string" } }, + maxRuntimeMs: { type: "number" }, + inactivityTimeoutMs: { type: "number" }, + sessionId: { type: "string" }, + stdoutCursor: { type: "number" }, + stderrCursor: { type: "number" }, + data: { type: "string" }, + stream: { type: "string", enum: ["stdout", "stderr"] }, + } as any, + execute: (params: any, ctx: Tool.Context) => { + const operation = params.operation + switch (operation) { + case "start": + return start(params, ctx) as any + case "poll": + return poll(params, ctx) as any + case "write": + return write(params, ctx) as any + case "interrupt": + return interrupt(params, ctx) as any + case "terminate": + return terminate(params, ctx) as any + case "list": + return list(params, ctx) as any + default: + return Effect.die(new Error(`Unknown operation: ${operation}`)) as any + } + }, + } + }), +) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 50a0d4d242b6..9a6c97efb954 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -35,7 +35,9 @@ import { pathToFileURL } from "url" import { Effect, Layer, Context } from "effect" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { CommandSession } from "@opencode-ai/core/command-session" import { Format } from "../format" +import { CommandSessionTool } from "./command-session" import { InstanceState } from "@/effect/instance-state" import { EffectBridge } from "@/effect/bridge" import { Question } from "../question" @@ -104,6 +106,7 @@ const layer = Layer.effect( const greptool = yield* GrepTool const patchtool = yield* ApplyPatchTool const skilltool = yield* SkillTool + const commandSession = yield* CommandSessionTool const agent = yield* Agent.Service const state = yield* InstanceState.make( @@ -211,6 +214,7 @@ const layer = Layer.effect( question: Tool.init(question), lsp: Tool.init(lsptool), plan: Tool.init(plan), + commandSession: Tool.init(commandSession), }) return { @@ -230,6 +234,7 @@ const layer = Layer.effect( tool.search, tool.skill, tool.patch, + tool.commandSession, ...(flags.experimentalLspTool ? [tool.lsp] : []), ...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []), ], diff --git a/packages/opencode/src/tool/verify-registration.ts b/packages/opencode/src/tool/verify-registration.ts new file mode 100644 index 000000000000..8757e9fa09ab --- /dev/null +++ b/packages/opencode/src/tool/verify-registration.ts @@ -0,0 +1,27 @@ +// Verify command_session tool is registered +import { Effect, Context, Layer } from "effect" +import { ToolRegistry } from "./registry" + +const program = Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const tools = yield* registry.all() + console.log("Tool count:", tools.length) + const cmdSession = tools.find((t: any) => t.id === "command_session") + if (cmdSession) { + console.log("✓ command_session tool found!") + console.log(" ID:", cmdSession.id) + console.log(" Description:", cmdSession.description) + } else { + console.log("✗ command_session tool NOT found") + console.log("Available tools:") + tools.forEach((t: any) => console.log(" -", t.id)) + } +}) + +Effect.runPromise(program).then( + () => process.exit(0), + (error) => { + console.error("Error:", error instanceof Error ? error.message : error) + process.exit(1) + }, +) diff --git a/packages/schema/src/command-event.ts b/packages/schema/src/command-event.ts new file mode 100644 index 000000000000..b9ae886b9b9d --- /dev/null +++ b/packages/schema/src/command-event.ts @@ -0,0 +1,68 @@ +export * as CommandEvent from "./command-event" + +import { Schema } from "effect" +import { define, inventory } from "./event" +import { ascending } from "./identifier" +import { NonNegativeInt } from "./schema" + +export const ID = Schema.String.pipe( + Schema.check(Schema.isStartsWith("cmd_")), + Schema.brand("CommandSessionID"), +) +export type ID = string + +export const Status = Schema.Literals([ + "starting", + "running", + "waiting_for_input", + "exited", + "failed", + "terminated", + "timed_out", + "lost", +]) +export type Status = "starting" | "running" | "waiting_for_input" | "exited" | "failed" | "terminated" | "timed_out" | "lost" + +export const Info = Schema.Struct({ + id: ID, + command: Schema.String, + args: Schema.Array(Schema.String), + cwd: Schema.String, + pid: NonNegativeInt, + status: Status, + exitCode: Schema.Union([NonNegativeInt, Schema.Null]), + signal: Schema.Union([Schema.String, Schema.Null]), + startedAt: Schema.String, + runtimeMs: NonNegativeInt, + idleMs: NonNegativeInt, + outputTruncated: Schema.Boolean.pipe(Schema.optional), + omittedBytes: NonNegativeInt.pipe(Schema.optional), +}) +export type Info = typeof Info.Type + +const Created = define({ type: "command.started", schema: { info: Info } }) +const Updated = define({ type: "command.updated", schema: { info: Info } }) +const OutputDelta = define({ type: "command.output_delta", schema: { sessionId: ID, stdout: Schema.String.pipe(Schema.optional), stderr: Schema.String.pipe(Schema.optional) } }) +const Exited = define({ type: "command.exited", schema: { id: ID, exitCode: NonNegativeInt, signal: Schema.String.pipe(Schema.optional) } }) +const Terminated = define({ type: "command.terminated", schema: { id: ID } }) +const TimedOut = define({ type: "command.timed_out", schema: { id: ID } }) +const InterruptRequested = define({ type: "command.interrupt_requested", schema: { id: ID } }) +const InputWritten = define({ type: "command.input_written", schema: { id: ID, bytes: NonNegativeInt } }) +const OutputTruncated = define({ type: "command.output_truncated", schema: { id: ID, omittedBytes: NonNegativeInt } }) +const Failed = define({ type: "command.failed", schema: { id: ID, error: Schema.String } }) +const Deleted = define({ type: "command.deleted", schema: { id: ID } }) + +export const Event = { + Created, + Updated, + OutputDelta, + Exited, + Terminated, + TimedOut, + InterruptRequested, + InputWritten, + OutputTruncated, + Failed, + Deleted, + Definitions: inventory(Created, Updated, OutputDelta, Exited, Terminated, TimedOut, InterruptRequested, InputWritten, OutputTruncated, Failed, Deleted), +}