Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,23 @@ const InfoSchema = Schema.Struct({
}),
}),
),
statusLine: Schema.optional(
Schema.Struct({
type: Schema.Literals(["command"]).annotate({
description: "Status line source type. Currently only 'command' is supported.",
}),
command: Schema.String.annotate({
description:
"Shell command whose stdout becomes the status line. Runs in the session's working directory and is refreshed on a throttle. Keep output to a single short line.",
}),
padding: Schema.optional(NonNegativeInt).annotate({
description: "Horizontal padding around the rendered status line, in cells.",
}),
}),
).annotate({
description:
"Custom status line. Mirrors the Claude Code statusLine config — stdout of the command becomes the status line text.",
}),
experimental: Schema.optional(
Schema.Struct({
disable_paste_summary: Schema.optional(Schema.Boolean),
Expand All @@ -232,6 +249,10 @@ const InfoSchema = Schema.Struct({
mcp_timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in milliseconds for model context protocol (MCP) requests",
}),
defer_tools: Schema.optional(Schema.Boolean).annotate({
description:
"Defer loading of non-core tool schemas until requested via a ToolSearch tool. Mirrors Claude Code's deferred-tool pattern to reduce prompt tokens when many MCP tools are available. Opt-in; the core set (Read/Edit/Write/Bash/Grep/Glob/Task) stays always-on.",
}),
}),
),
})
Expand Down
28 changes: 28 additions & 0 deletions packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,34 @@ export const layer = Layer.effect(
Effect.sync(() => {
for (const hook of hooks) {
void hook["event"]?.({ event: input as any })

// Bridge bus events to convenience lifecycle hooks. These
// mirror the Claude Code SessionStart / SessionEnd hook
// semantics so plugin authors can subscribe by name without
// pattern-matching on the event stream.
const evt = input as { type?: string; properties?: any }
if (evt?.type === "session.created" && evt.properties?.info) {
const info = evt.properties.info
void hook["session.start"]?.(
{
sessionID: info.id,
parentID: info.parentID,
directory: info.directory ?? "",
},
{},
)
}
if (evt?.type === "session.deleted" && evt.properties?.info) {
const info = evt.properties.info
void hook["session.end"]?.(
{
sessionID: info.id,
parentID: info.parentID,
directory: info.directory ?? "",
},
{},
)
}
}
}),
),
Expand Down
34 changes: 33 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1277,6 +1277,22 @@ NOTE: At any point in time through this workflow you should feel free to ask the
function* (input: PromptInput) {
const session = yield* sessions.get(input.sessionID)
yield* revert.cleanup(session)

// Allow plugins to inject context parts or block the prompt before
// the LLM is called. Mutations to `parts` are merged in; setting
// `block` short-circuits with an error.
const submit = yield* plugin.trigger(
"chat.prompt.submit",
{ sessionID: input.sessionID, agent: input.agent, messageID: input.messageID },
{ parts: [] as PromptInput["parts"], block: undefined as string | undefined },
)
if (submit.block) {
throw new NamedError.Unknown({ message: `Prompt blocked by plugin: ${submit.block}` })
}
if (submit.parts.length > 0) {
input = { ...input, parts: [...input.parts, ...submit.parts] }
}

const message = yield* createUserMessage(input)
yield* sessions.touch(input.sessionID)

Expand All @@ -1290,7 +1306,23 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}

if (input.noReply === true) return message
return yield* loop({ sessionID: input.sessionID })

const stop = (msgID: string, aborted: boolean) =>
plugin.trigger(
"chat.stop",
{
sessionID: input.sessionID,
messageID: msgID,
agent: input.agent ?? "",
aborted,
},
{},
)

return yield* loop({ sessionID: input.sessionID }).pipe(
Effect.tap((result) => stop(result.info.id, false)),
Effect.tapError(() => stop(message.info.id, true).pipe(Effect.ignore)),
)
},
)

Expand Down
226 changes: 226 additions & 0 deletions packages/opencode/src/shell/background.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { spawn, type ChildProcess } from "child_process"
import { ulid } from "ulid"
import { Effect, Layer, Context, Schema } from "effect"
import { InstanceState } from "@/effect"
import { Log } from "@/util"
import { killTree } from "./shell"

const log = Log.create({ service: "shell.background" })

const MAX_BUFFER_BYTES = 1_000_000 // 1 MB rolling window per shell

type Status = "running" | "exited" | "killed" | "errored"

type Handle = {
id: string
command: string
description?: string
startedAt: number
proc: ChildProcess
buffer: string[]
bufferBytes: number
status: Status
exitCode: number | null
error?: string
exited: boolean
}

export type StartInput = {
shell: string
shellName: string
command: string
cwd: string
env: NodeJS.ProcessEnv
description?: string
}

export type OutputResult = {
shellID: string
command: string
description?: string
status: Status
exitCode: number | null
error?: string
startedAt: number
output: string
truncated: boolean
}

const PS_NAMES = new Set(["powershell", "pwsh"])

function buildSpawn(input: StartInput): ChildProcess {
if (process.platform === "win32" && PS_NAMES.has(input.shellName)) {
return spawn(
input.shell,
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", input.command],
{
cwd: input.cwd,
env: input.env,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
},
)
}
return spawn(input.command, [], {
shell: input.shell,
cwd: input.cwd,
env: input.env,
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
})
}

function appendBuffer(handle: Handle, chunk: string) {
if (!chunk) return
const bytes = Buffer.byteLength(chunk, "utf-8")
handle.buffer.push(chunk)
handle.bufferBytes += bytes
while (handle.bufferBytes > MAX_BUFFER_BYTES && handle.buffer.length > 1) {
const removed = handle.buffer.shift()
if (!removed) break
handle.bufferBytes -= Buffer.byteLength(removed, "utf-8")
}
}

function drainBuffer(handle: Handle): { text: string; truncated: boolean } {
const text = handle.buffer.join("")
const truncated = handle.bufferBytes >= MAX_BUFFER_BYTES
handle.buffer = []
handle.bufferBytes = 0
return { text, truncated }
}

function startHandle(input: StartInput): Handle {
const id = ulid().toLowerCase()
const proc = buildSpawn(input)
const handle: Handle = {
id,
command: input.command,
description: input.description,
startedAt: Date.now(),
proc,
buffer: [],
bufferBytes: 0,
status: "running",
exitCode: null,
exited: false,
}
proc.stdout?.on("data", (chunk: Buffer | string) => {
appendBuffer(handle, typeof chunk === "string" ? chunk : chunk.toString("utf-8"))
})
proc.stderr?.on("data", (chunk: Buffer | string) => {
appendBuffer(handle, typeof chunk === "string" ? chunk : chunk.toString("utf-8"))
})
proc.on("error", (err) => {
handle.status = "errored"
handle.error = String(err?.message ?? err)
handle.exited = true
log.error("background shell errored", { id, error: handle.error })
})
proc.on("exit", (code) => {
handle.exited = true
if (handle.status === "running") {
handle.status = code === 0 ? "exited" : code === null ? "killed" : "exited"
}
handle.exitCode = code
log.info("background shell exited", { id, code, status: handle.status })
})
log.info("background shell started", { id, command: input.command })
return handle
}

function snapshot(handle: Handle): OutputResult {
const drained = drainBuffer(handle)
return {
shellID: handle.id,
command: handle.command,
description: handle.description,
status: handle.status,
exitCode: handle.exitCode,
error: handle.error,
startedAt: handle.startedAt,
output: drained.text,
truncated: drained.truncated,
}
}

async function killAll(shells: Map<string, Handle>) {
for (const handle of shells.values()) {
if (handle.exited) continue
try {
await killTree(handle.proc, { exited: () => handle.exited })
} catch (err) {
log.error("kill on shutdown failed", { id: handle.id, error: String(err) })
}
}
shells.clear()
}

type State = {
shells: Map<string, Handle>
}

export class ShellNotFound extends Schema.TaggedErrorClass<ShellNotFound>()("ShellNotFound", {
shellID: Schema.String,
}) {}

export interface Interface {
readonly start: (input: StartInput) => Effect.Effect<{ shellID: string }>
readonly output: (input: { shellID: string }) => Effect.Effect<OutputResult, ShellNotFound>
readonly kill: (input: { shellID: string }) => Effect.Effect<OutputResult, ShellNotFound>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/BackgroundShell") {}

export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("BackgroundShell.state")(function* (_ctx) {
const shells = new Map<string, Handle>()
yield* Effect.addFinalizer(() => Effect.promise(() => killAll(shells)))
return { shells }
}),
)

return Service.of({
start: Effect.fn("BackgroundShell.start")(function* (input: StartInput) {
return yield* InstanceState.useEffect(state, (s) =>
Effect.sync(() => {
const handle = startHandle(input)
s.shells.set(handle.id, handle)
return { shellID: handle.id }
}),
)
}),
output: Effect.fn("BackgroundShell.output")(function* (input: { shellID: string }) {
return yield* InstanceState.useEffect(state, (s) =>
Effect.gen(function* () {
const handle = s.shells.get(input.shellID)
if (!handle) return yield* Effect.fail(new ShellNotFound({ shellID: input.shellID }))
return snapshot(handle)
}),
)
}),
kill: Effect.fn("BackgroundShell.kill")(function* (input: { shellID: string }) {
return yield* InstanceState.useEffect(state, (s) =>
Effect.gen(function* () {
const handle = s.shells.get(input.shellID)
if (!handle) return yield* Effect.fail(new ShellNotFound({ shellID: input.shellID }))
if (!handle.exited) {
yield* Effect.promise(() => killTree(handle.proc, { exited: () => handle.exited }))
if (handle.status === "running") handle.status = "killed"
}
const result = snapshot(handle)
s.shells.delete(input.shellID)
return result
}),
)
}),
})
}),
)

export const defaultLayer = layer

export * as BackgroundShell from "./background"
Loading