diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts
index f609986b5837..68bd57ed0cbf 100644
--- a/packages/opencode/src/effect/run-service.ts
+++ b/packages/opencode/src/effect/run-service.ts
@@ -7,7 +7,7 @@ import { Observability } from "./oltp"
export const memoMap = Layer.makeMemoMapUnsafe()
-function attach(effect: Effect.Effect): Effect.Effect {
+export function attach(effect: Effect.Effect): Effect.Effect {
try {
const ctx = Instance.current
return Effect.provideService(effect, InstanceRef, ctx)
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 7f0a014ab249..c3275ad72926 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -45,7 +45,7 @@ import { decodeDataUrl } from "@/util/data-url"
import { Process } from "@/util/process"
import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
-import { makeRuntime } from "@/effect/run-service"
+import { attach, makeRuntime } from "@/effect/run-service"
import { TaskTool } from "@/tool/task"
import { SessionRunState } from "./run-state"
@@ -62,6 +62,64 @@ IMPORTANT:
const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.`
+/**
+ * Bridges an AI SDK Promise-based `execute` callback to Effect with graceful
+ * cancel semantics.
+ *
+ * On the happy path: runs `before`, awaits `execute()`, then `finalize(result)`
+ * and returns the output.
+ *
+ * On cancel mid-flight: the `onInterrupt` finalizer re-awaits the same in-flight
+ * native Promise uninterruptibly, runs `finalize` again on the eventual result,
+ * and posts it via `onCancel` (the processor side channel). This is what lets
+ * cancelled bash surface its truncated output through the normal completion
+ * path instead of getting stamped as aborted by processor cleanup.
+ *
+ * The returned Promise always resolves with a finalized output when one is
+ * available (even on interrupt), so the SDK reports the tool as successfully
+ * completed rather than as a tool-error.
+ *
+ * `attach` captures the current Instance context via InstanceRef so the
+ * onInterrupt finalizer — which runs outside the AsyncLocalStorage chain
+ * `execute()` is called from — can still resolve it through the ServiceMap.
+ */
+function runToolExecute(options: {
+ signal: AbortSignal | undefined
+ before: Effect.Effect
+ execute: () => Promise
+ finalize: (result: Raw) => Effect.Effect