Skip to content

Commit 1ee67f1

Browse files
committed
feat(opencode): add subagent interrupt (steer/cancel/abort)
Experimental capability for a parent agent or human operator to steer, gracefully cancel, or hard-abort a specific running Task subagent mid-run, without affecting the parent or sibling subagents. Core: - Interrupt service (session/interrupt.ts): process-local registry holding one pending interrupt per child plus a terminal record; steer/cancel frame renderers and a visible-marker renderer, both with origin attribution (user vs parent); reason length-capped and XML-escaped at every sink (frames AND the visible marker). - The child consumes pending interrupts at the runLoop turn boundary: steer injects a <steer> frame and a visible "Steered by ..." marker and continues; cancel injects <cancel> + a visible marker, records a terminal, and force-breaks within a grace window. abortChild writes a visible "Aborted by ..." marker (model/agent derived from the child's latest user message), records a terminal, and cancels the BackgroundJob. Agent tools (gated by permission.interrupt): - task_steer / task_cancel / task_abort (origin=parent). Human paths: - POST /session/:id/interrupt (intent steer|cancel|abort, origin=user), restricted to subagent sessions, gated by the experimental flag, and rejecting non-running children. - TUI: esc on a subagent opens a Steer/Cancel/Abort menu, then a reason prompt; markers render as "... by user". Bound at the session route via a uniquely-named gather bucket (the keymap gather() caches by name). Visible interrupt markers render as a distinct "Interrupt" line (tagged via part.metadata.interrupt), not as user prose. Whole feature gated by OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT (off by default): agent tools, HTTP endpoint, and TUI affordance. Limitations: agent-driven steer/cancel applies to background children only (a foreground child blocks the parent turn); cancel is boundary-soft (use task_abort / Abort for a child stuck in a long tool call).
1 parent 10b6672 commit 1ee67f1

29 files changed

Lines changed: 1811 additions & 22 deletions

packages/core/src/v1/config/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,10 @@ export const Info = Schema.Struct({
179179
mcp_timeout: Schema.optional(PositiveInt).annotate({
180180
description: "Timeout in milliseconds for model context protocol (MCP) requests",
181181
}),
182+
subagent_interrupt: Schema.optional(Schema.Boolean).annotate({
183+
description:
184+
"Enable the subagent interrupt HTTP endpoint and TUI esc-with-reason UX. Server-controlled; reflects the OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT runtime flag.",
185+
}),
182186
policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({
183187
description: "Policy statements applied to supported resources, such as provider access",
184188
}),

packages/core/src/v1/config/permission.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const InputObject = Schema.StructWithRest(
2626
external_directory: Schema.optional(Rule),
2727
todowrite: Schema.optional(Action),
2828
question: Schema.optional(Action),
29+
interrupt: Schema.optional(Action),
2930
webfetch: Schema.optional(Action),
3031
websearch: Schema.optional(Action),
3132
lsp: Schema.optional(Rule),

packages/opencode/src/effect/app-runtime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { SessionCompaction } from "@/session/compaction"
2929
import { SessionRevert } from "@/session/revert"
3030
import { SessionSummary } from "@/session/summary"
3131
import { SessionPrompt } from "@/session/prompt"
32+
import { Interrupt } from "@/session/interrupt"
3233
import { Instruction } from "@/session/instruction"
3334
import { LLM } from "@/session/llm"
3435
import { LSP } from "@/lsp/lsp"
@@ -83,6 +84,7 @@ export const AppLayer = Layer.mergeAll(
8384
SessionRevert.defaultLayer,
8485
SessionSummary.defaultLayer,
8586
SessionPrompt.defaultLayer,
87+
Interrupt.defaultLayer,
8688
Instruction.defaultLayer,
8789
LLM.defaultLayer,
8890
LSP.defaultLayer,

packages/opencode/src/effect/runtime-flags.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
4141
enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"),
4242
experimentalReferences: enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES"),
4343
experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"),
44+
experimentalSubagentInterrupt: enabledByExperimental("OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT"),
4445
experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"),
4546
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
4647
experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"),

packages/opencode/src/server/routes/instance/httpapi/groups/session.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ export const SummarizePayload = Schema.Struct({
6767
modelID: ModelV2.ID,
6868
auto: Schema.optional(Schema.Boolean),
6969
})
70+
export const InterruptPayload = Schema.Struct({
71+
intent: Schema.Literals(["steer", "cancel", "abort"]),
72+
reason: Schema.String,
73+
})
7074
export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]))
7175
export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"]))
7276
export const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"]))
@@ -89,6 +93,7 @@ export const SessionPaths = {
8993
update: `${root}/:sessionID`,
9094
fork: `${root}/:sessionID/fork`,
9195
abort: `${root}/:sessionID/abort`,
96+
interrupt: `${root}/:sessionID/interrupt`,
9297
share: `${root}/:sessionID/share`,
9398
init: `${root}/:sessionID/init`,
9499
summarize: `${root}/:sessionID/summarize`,
@@ -262,6 +267,19 @@ export const SessionApi = HttpApi.make("session")
262267
description: "Abort an active session and stop any ongoing AI processing or command execution.",
263268
}),
264269
),
270+
HttpApiEndpoint.post("interrupt", SessionPaths.interrupt, {
271+
params: { sessionID: SessionID },
272+
query: WorkspaceRoutingQuery,
273+
payload: InterruptPayload,
274+
success: described(Schema.Boolean, "Interrupt requested"),
275+
error: HttpApiError.BadRequest,
276+
}).annotateMerge(
277+
OpenApi.annotations({
278+
identifier: "session.interrupt",
279+
summary: "Interrupt session",
280+
description: "Steer or gracefully cancel an active session (human/operator path; not permission-gated).",
281+
}),
282+
),
265283
HttpApiEndpoint.post("init", SessionPaths.init, {
266284
params: { sessionID: SessionID },
267285
query: WorkspaceRoutingQuery,

packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Config } from "@/config/config"
22
import { Provider } from "@/provider/provider"
3+
import { RuntimeFlags } from "@/effect/runtime-flags"
34
import * as InstanceState from "@/effect/instance-state"
45
import { Effect } from "effect"
56
import { HttpApiBuilder } from "effect/unstable/httpapi"
@@ -10,9 +11,19 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h
1011
Effect.gen(function* () {
1112
const providerSvc = yield* Provider.Service
1213
const configSvc = yield* Config.Service
14+
const flags = yield* RuntimeFlags.Service
1315

1416
const get = Effect.fn("ConfigHttpApi.get")(function* () {
15-
return yield* configSvc.get()
17+
const info = yield* configSvc.get()
18+
// Surface the subagent-interrupt runtime flag in the TUI-visible config.
19+
// The HTTP endpoint (and TUI UX) is off-by-default and gated by env var.
20+
return {
21+
...info,
22+
experimental: {
23+
...info.experimental,
24+
subagent_interrupt: flags.experimentalSubagentInterrupt,
25+
},
26+
}
1627
})
1728

1829
const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) {

packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
33
import { EffectBridge } from "@/effect/bridge"
44
import { EventV2 } from "@opencode-ai/core/event"
55
import { Installation } from "@/installation"
6+
import { RuntimeFlags } from "@/effect/runtime-flags"
67
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
78
import { InstallationVersion } from "@opencode-ai/core/installation/version"
89
import { Effect, Queue, Schema } from "effect"
@@ -70,6 +71,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
7071
const config = yield* Config.Service
7172
const installation = yield* Installation.Service
7273
const bridge = yield* EffectBridge.make()
74+
const flags = yield* RuntimeFlags.Service
7375

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

8284
const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () {
83-
return yield* config.getGlobal()
85+
const info = yield* config.getGlobal()
86+
return {
87+
...info,
88+
experimental: {
89+
...info.experimental,
90+
subagent_interrupt: flags.experimentalSubagentInterrupt,
91+
},
92+
}
8493
})
8594

8695
const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) {

packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
22
import { Agent } from "@/agent/agent"
3+
import { BackgroundJob } from "@/background/job"
34
import { SessionV1 } from "@opencode-ai/core/v1/session"
45
import { EventV2Bridge } from "@/event-v2-bridge"
56
import { Command } from "@/command"
@@ -13,9 +14,11 @@ import { SessionRevert } from "@/session/revert"
1314
import { SessionRunState } from "@/session/run-state"
1415
import { SessionStatus } from "@/session/status"
1516
import { SessionSummary } from "@/session/summary"
17+
import { Interrupt } from "@/session/interrupt"
1618
import { Todo } from "@/session/todo"
1719
import { MessageID, PartID, SessionID } from "@/session/schema"
1820
import { NamedError } from "@opencode-ai/core/util/error"
21+
import { RuntimeFlags } from "@/effect/runtime-flags"
1922
import { Cause, Effect, Option, Schema, Scope } from "effect"
2023
import * as Stream from "effect/Stream"
2124
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -26,6 +29,7 @@ import {
2629
DiffQuery,
2730
ForkPayload,
2831
InitPayload,
32+
InterruptPayload,
2933
ListQuery,
3034
MessagesQuery,
3135
PermissionResponsePayload,
@@ -56,8 +60,11 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
5660
const permissionSvc = yield* Permission.Service
5761
const statusSvc = yield* SessionStatus.Service
5862
const todoSvc = yield* Todo.Service
63+
const interruptSvc = yield* Interrupt.Service
64+
const backgroundSvc = yield* BackgroundJob.Service
5965
const summary = yield* SessionSummary.Service
6066
const events = yield* EventV2Bridge.Service
67+
const flags = yield* RuntimeFlags.Service
6168
const scope = yield* Scope.Scope
6269

6370
const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) {
@@ -232,6 +239,42 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
232239
return true
233240
})
234241

242+
const interrupt = Effect.fn("SessionHttpApi.interrupt")(function* (ctx: {
243+
params: { sessionID: SessionID }
244+
payload: typeof InterruptPayload.Type
245+
}) {
246+
if (!flags.experimentalSubagentInterrupt) return yield* new HttpApiError.BadRequest({})
247+
const target = yield* session
248+
.get(ctx.params.sessionID)
249+
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
250+
// This endpoint is the subagent escape-with-reason path. Injecting into a
251+
// root session (no parentID) would let any caller steer the main session.
252+
if (!target.parentID) return yield* new HttpApiError.BadRequest({})
253+
// Reject if the child is not currently running. Without this guard, steer
254+
// and cancel would leave a stale pending interrupt on a finished child;
255+
// abort would record a terminal on a child that has already settled. The
256+
// task_steer/task_cancel/task_abort tools already make this same
257+
// running-only guarantee via resolveChild in task-interrupt.ts.
258+
const job = yield* backgroundSvc.get(ctx.params.sessionID)
259+
if (!job || job.status !== "running") return yield* new HttpApiError.BadRequest({})
260+
if (ctx.payload.intent === "abort") {
261+
// Abort bypasses the pending-intent slot — it writes a visible marker,
262+
// records a terminal reason, and cancels the BackgroundJob immediately.
263+
yield* Interrupt.abortChild(
264+
{ sessions: session, background: backgroundSvc, interrupt: interruptSvc },
265+
{ childID: ctx.params.sessionID, origin: "user", reason: ctx.payload.reason },
266+
)
267+
} else {
268+
yield* interruptSvc.request({
269+
sessionID: ctx.params.sessionID,
270+
intent: ctx.payload.intent,
271+
reason: ctx.payload.reason,
272+
origin: "user",
273+
})
274+
}
275+
return true
276+
})
277+
235278
const init = Effect.fn("SessionHttpApi.init")(function* (ctx: {
236279
params: { sessionID: SessionID }
237280
payload: typeof InitPayload.Type
@@ -422,6 +465,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
422465
.handle("update", update)
423466
.handleRaw("fork", forkRaw)
424467
.handle("abort", abort)
468+
.handle("interrupt", interrupt)
425469
.handle("init", init)
426470
.handle("share", share)
427471
.handle("unshare", unshare)

packages/opencode/src/server/routes/instance/httpapi/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { Provider } from "@/provider/provider"
3030
import { Question } from "@/question"
3131
import { SessionCompaction } from "@/session/compaction"
3232
import { Instruction } from "@/session/instruction"
33+
import { Interrupt } from "@/session/interrupt"
3334
import { LLM } from "@/session/llm"
3435
import { SessionProcessor } from "@/session/processor"
3536
import { SessionPrompt } from "@/session/prompt"
@@ -234,6 +235,7 @@ const app = LayerNode.group([
234235
SessionRevert.node,
235236
SessionSummary.node,
236237
SessionPrompt.node,
238+
Interrupt.node,
237239
Instruction.node,
238240
LLM.node,
239241
LSP.node,

0 commit comments

Comments
 (0)