Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
feat(opencode): interrupt a running subagent — steer / cancel / abort
Lets a parent agent (via tools) or the human (via TUI esc) steer, gracefully
cancel, or hard-abort a single running Task subagent mid-run, without
affecting the parent or sibling subagents.

A per-instance Interrupt service holds one pending interrupt per child plus a
terminal record; the child consumes it at its own runLoop turn boundary.
Steer injects a frame and the child adapts and continues; cancel injects a
frame, records a terminal, and force-breaks within a grace window; abort
cancels the BackgroundJob immediately. Interrupt tools reach any descendant
through a bounded ancestry walk that fails closed off the caller's subtree.

Off by default behind OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT.
  • Loading branch information
iceteaSA committed Aug 22, 2026
commit 3eab009b14e3660405117964160062bb8e0bb21a
4 changes: 4 additions & 0 deletions packages/core/src/v1/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ export const Info = Schema.Struct({
mcp_timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in milliseconds for model context protocol (MCP) requests",
}),
subagent_interrupt: Schema.optional(Schema.Boolean).annotate({
description:
"Enable the subagent interrupt HTTP endpoint and TUI esc-with-reason UX. Server-controlled; reflects the OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT runtime flag.",
}),
policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({
description: "Policy statements applied to supported resources, such as provider access",
}),
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/v1/config/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const InputObject = Schema.StructWithRest(
external_directory: Schema.optional(Rule),
todowrite: Schema.optional(Action),
question: Schema.optional(Action),
interrupt: Schema.optional(Action),
webfetch: Schema.optional(Action),
websearch: Schema.optional(Action),
lsp: Schema.optional(Rule),
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/effect/app-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { SessionCompaction } from "@/session/compaction"
import { SessionRevert } from "@/session/revert"
import { SessionSummary } from "@/session/summary"
import { SessionPrompt } from "@/session/prompt"
import { Interrupt } from "@/session/interrupt"
import { Instruction } from "@/session/instruction"
import { LLM } from "@/session/llm"
import { LSP } from "@/lsp/lsp"
Expand Down Expand Up @@ -88,6 +89,7 @@ export const AppLayer = AppNodeBuilderV1.build(
SessionRevert.node,
SessionSummary.node,
SessionPrompt.node,
Interrupt.node,
Instruction.node,
LLM.node,
LSP.node,
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/effect/runtime-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"),
experimentalReferences: enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES"),
experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"),
experimentalSubagentInterrupt: enabledByExperimental("OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT"),
experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"),
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ export const SummarizePayload = Schema.Struct({
modelID: ModelV2.ID,
auto: Schema.optional(Schema.Boolean),
})
export const InterruptPayload = Schema.Struct({
intent: Schema.Literals(["steer", "cancel", "abort"]),
reason: Schema.String,
})
export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]))
export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"]))
export const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"]))
Expand All @@ -89,6 +93,7 @@ export const SessionPaths = {
update: `${root}/:sessionID`,
fork: `${root}/:sessionID/fork`,
abort: `${root}/:sessionID/abort`,
interrupt: `${root}/:sessionID/interrupt`,
share: `${root}/:sessionID/share`,
init: `${root}/:sessionID/init`,
summarize: `${root}/:sessionID/summarize`,
Expand Down Expand Up @@ -262,6 +267,19 @@ export const SessionApi = HttpApi.make("session")
description: "Abort an active session and stop any ongoing AI processing or command execution.",
}),
),
HttpApiEndpoint.post("interrupt", SessionPaths.interrupt, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: InterruptPayload,
success: described(Schema.Boolean, "Interrupt requested"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.interrupt",
summary: "Interrupt session",
description: "Steer or gracefully cancel an active session (human/operator path; not permission-gated).",
}),
),
HttpApiEndpoint.post("init", SessionPaths.init, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Config } from "@/config/config"
import { Provider } from "@/provider/provider"
import { RuntimeFlags } from "@/effect/runtime-flags"
import * as InstanceState from "@/effect/instance-state"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
Expand All @@ -10,9 +11,19 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h
Effect.gen(function* () {
const providerSvc = yield* Provider.Service
const configSvc = yield* Config.Service
const flags = yield* RuntimeFlags.Service

const get = Effect.fn("ConfigHttpApi.get")(function* () {
return yield* configSvc.get()
const info = yield* configSvc.get()
// Surface the subagent-interrupt runtime flag in the TUI-visible config.
// The HTTP endpoint (and TUI UX) is off-by-default and gated by env var.
return {
...info,
experimental: {
...info.experimental,
subagent_interrupt: flags.experimentalSubagentInterrupt,
},
}
})

const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
import { EffectBridge } from "@/effect/bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { Installation } from "@/installation"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Effect, Queue, Schema } from "effect"
Expand Down Expand Up @@ -70,6 +71,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
const config = yield* Config.Service
const installation = yield* Installation.Service
const bridge = yield* EffectBridge.make()
const flags = yield* RuntimeFlags.Service

const health = Effect.fn("GlobalHttpApi.health")(function* () {
return { healthy: true as const, version: InstallationVersion }
Expand All @@ -80,7 +82,14 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
})

const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () {
return yield* config.getGlobal()
const info = yield* config.getGlobal()
return {
...info,
experimental: {
...info.experimental,
subagent_interrupt: flags.experimentalSubagentInterrupt,
},
}
})

const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Agent } from "@/agent/agent"
import { BackgroundJob } from "@/background/job"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Command } from "@/command"
Expand All @@ -13,9 +14,11 @@ import { SessionRevert } from "@/session/revert"
import { SessionRunState } from "@/session/run-state"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Interrupt } from "@/session/interrupt"
import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema"
import { NamedError } from "@opencode-ai/core/util/error"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Cause, Effect, Option, Schema, Scope } from "effect"
import * as Stream from "effect/Stream"
import { InstanceState } from "@/effect/instance-state"
Expand All @@ -27,6 +30,7 @@ import {
DiffQuery,
ForkPayload,
InitPayload,
InterruptPayload,
ListQuery,
MessagesQuery,
PermissionResponsePayload,
Expand Down Expand Up @@ -57,8 +61,11 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
const permissionSvc = yield* Permission.Service
const statusSvc = yield* SessionStatus.Service
const todoSvc = yield* Todo.Service
const interruptSvc = yield* Interrupt.Service
const backgroundSvc = yield* BackgroundJob.Service
const summary = yield* SessionSummary.Service
const events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service
const scope = yield* Scope.Scope

const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) {
Expand Down Expand Up @@ -234,6 +241,42 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
return true
})

const interrupt = Effect.fn("SessionHttpApi.interrupt")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof InterruptPayload.Type
}) {
if (!flags.experimentalSubagentInterrupt) return yield* new HttpApiError.BadRequest({})
const target = yield* session
.get(ctx.params.sessionID)
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
// This endpoint is the subagent escape-with-reason path. Injecting into a
// root session (no parentID) would let any caller steer the main session.
if (!target.parentID) return yield* new HttpApiError.BadRequest({})
// Reject if the child is not currently running. Without this guard, steer
// and cancel would leave a stale pending interrupt on a finished child;
// abort would record a terminal on a child that has already settled. The
// task_steer/task_cancel/task_abort tools already make this same
// running-only guarantee via resolveChild in task-interrupt.ts.
const job = yield* backgroundSvc.get(ctx.params.sessionID)
if (!job || job.status !== "running") return yield* new HttpApiError.BadRequest({})
if (ctx.payload.intent === "abort") {
// Abort bypasses the pending-intent slot — it writes a visible marker,
// records a terminal reason, and cancels the BackgroundJob immediately.
yield* Interrupt.abortChild(
{ sessions: session, background: backgroundSvc, interrupt: interruptSvc },
{ childID: ctx.params.sessionID, origin: "user", reason: ctx.payload.reason },
)
} else {
yield* interruptSvc.request({
sessionID: ctx.params.sessionID,
intent: ctx.payload.intent,
reason: ctx.payload.reason,
origin: "user",
})
}
return true
})

const init = Effect.fn("SessionHttpApi.init")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof InitPayload.Type
Expand Down Expand Up @@ -424,6 +467,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
.handle("update", update)
.handleRaw("fork", forkRaw)
.handle("abort", abort)
.handle("interrupt", interrupt)
.handle("init", init)
.handle("share", share)
.handle("unshare", unshare)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { Provider } from "@/provider/provider"
import { Question } from "@/question"
import { SessionCompaction } from "@/session/compaction"
import { Instruction } from "@/session/instruction"
import { Interrupt } from "@/session/interrupt"
import { LLM } from "@/session/llm"
import { SessionProcessor } from "@/session/processor"
import { SessionPrompt } from "@/session/prompt"
Expand Down Expand Up @@ -244,6 +245,7 @@ const app = LayerNode.group([
SessionRevert.node,
SessionSummary.node,
SessionPrompt.node,
Interrupt.node,
Instruction.node,
LLM.node,
LSP.node,
Expand Down
Loading
Loading