Kaynağa Gözat

feat: unwrap session namespaces to flat exports + barrel

Kit Langton 2 gün önce
ebeveyn
işleme
a83e989ffa
54 değiştirilmiş dosya ile 4598 ekleme ve 4598 silme
  1. 2 2
      packages/opencode/src/acp/agent.ts
  2. 1 1
      packages/opencode/src/cli/cmd/debug/agent.ts
  3. 1 1
      packages/opencode/src/cli/cmd/export.ts
  4. 2 2
      packages/opencode/src/cli/cmd/github.ts
  5. 1 1
      packages/opencode/src/cli/cmd/import.ts
  6. 1 1
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  7. 10 10
      packages/opencode/src/effect/app-runtime.ts
  8. 1 1
      packages/opencode/src/plugin/github-copilot/copilot.ts
  9. 8 8
      packages/opencode/src/server/instance/session.ts
  10. 335 337
      packages/opencode/src/session/compaction.ts
  11. 14 0
      packages/opencode/src/session/index.ts
  12. 165 167
      packages/opencode/src/session/instruction.ts
  13. 392 394
      packages/opencode/src/session/llm.ts
  14. 665 653
      packages/opencode/src/session/message-v2.ts
  15. 169 171
      packages/opencode/src/session/message.ts
  16. 1 1
      packages/opencode/src/session/overflow.ts
  17. 546 548
      packages/opencode/src/session/processor.ts
  18. 1 1
      packages/opencode/src/session/projectors.ts
  19. 1568 1570
      packages/opencode/src/session/prompt.ts
  20. 99 101
      packages/opencode/src/session/retry.ts
  21. 126 128
      packages/opencode/src/session/revert.ts
  22. 89 91
      packages/opencode/src/session/run-state.ts
  23. 1 1
      packages/opencode/src/session/session.sql.ts
  24. 1 1
      packages/opencode/src/session/session.ts
  25. 69 71
      packages/opencode/src/session/status.ts
  26. 141 143
      packages/opencode/src/session/summary.ts
  27. 54 56
      packages/opencode/src/session/system.ts
  28. 67 69
      packages/opencode/src/session/todo.ts
  29. 1 1
      packages/opencode/src/share/share-next.ts
  30. 1 1
      packages/opencode/src/tool/plan.ts
  31. 1 1
      packages/opencode/src/tool/read.ts
  32. 2 2
      packages/opencode/src/tool/registry.ts
  33. 2 2
      packages/opencode/src/tool/task.ts
  34. 1 1
      packages/opencode/src/tool/todo.ts
  35. 1 1
      packages/opencode/src/tool/tool.ts
  36. 1 1
      packages/opencode/test/cli/github-action.test.ts
  37. 1 1
      packages/opencode/test/server/session-messages.test.ts
  38. 6 6
      packages/opencode/test/session/compaction.test.ts
  39. 2 2
      packages/opencode/test/session/instruction.test.ts
  40. 2 2
      packages/opencode/test/session/llm.test.ts
  41. 1 1
      packages/opencode/test/session/message-v2.test.ts
  42. 1 1
      packages/opencode/test/session/messages-pagination.test.ts
  43. 5 5
      packages/opencode/test/session/processor-effect.test.ts
  44. 12 12
      packages/opencode/test/session/prompt-effect.test.ts
  45. 2 2
      packages/opencode/test/session/prompt.test.ts
  46. 3 3
      packages/opencode/test/session/retry.test.ts
  47. 2 2
      packages/opencode/test/session/revert-compact.test.ts
  48. 1 1
      packages/opencode/test/session/session.test.ts
  49. 12 12
      packages/opencode/test/session/snapshot-tool-race.test.ts
  50. 2 2
      packages/opencode/test/session/structured-output-integration.test.ts
  51. 2 2
      packages/opencode/test/session/structured-output.test.ts
  52. 1 1
      packages/opencode/test/session/system.test.ts
  53. 1 1
      packages/opencode/test/tool/read.test.ts
  54. 2 2
      packages/opencode/test/tool/task.test.ts

+ 2 - 2
packages/opencode/src/acp/agent.ts

@@ -42,9 +42,9 @@ import { ModelID, ProviderID } from "../provider/schema"
 import { Agent as AgentModule } from "../agent/agent"
 import { AppRuntime } from "@/effect/app-runtime"
 import { Installation } from "@/installation"
-import { MessageV2 } from "@/session/message-v2"
+import { MessageV2 } from "@/session"
 import { Config } from "@/config"
-import { Todo } from "@/session/todo"
+import { Todo } from "@/session"
 import { z } from "zod"
 import { LoadAPIKeyError } from "ai"
 import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"

+ 1 - 1
packages/opencode/src/cli/cmd/debug/agent.ts

@@ -4,7 +4,7 @@ import { Effect } from "effect"
 import { Agent } from "../../../agent/agent"
 import { Provider } from "../../../provider"
 import { Session } from "../../../session"
-import type { MessageV2 } from "../../../session/message-v2"
+import type { MessageV2 } from "../../../session"
 import { MessageID, PartID } from "../../../session/schema"
 import { ToolRegistry } from "../../../tool/registry"
 import { Instance } from "../../../project/instance"

+ 1 - 1
packages/opencode/src/cli/cmd/export.ts

@@ -1,6 +1,6 @@
 import type { Argv } from "yargs"
 import { Session } from "../../session"
-import { MessageV2 } from "../../session/message-v2"
+import { MessageV2 } from "../../session"
 import { SessionID } from "../../session/schema"
 import { cmd } from "./cmd"
 import { bootstrap } from "../bootstrap"

+ 2 - 2
packages/opencode/src/cli/cmd/github.ts

@@ -27,8 +27,8 @@ import type { SessionID } from "../../session/schema"
 import { MessageID, PartID } from "../../session/schema"
 import { Provider } from "../../provider"
 import { Bus } from "../../bus"
-import { MessageV2 } from "../../session/message-v2"
-import { SessionPrompt } from "@/session/prompt"
+import { MessageV2 } from "../../session"
+import { SessionPrompt } from "@/session"
 import { AppRuntime } from "@/effect/app-runtime"
 import { Git } from "@/git"
 import { setTimeout as sleep } from "node:timers/promises"

+ 1 - 1
packages/opencode/src/cli/cmd/import.ts

@@ -1,7 +1,7 @@
 import type { Argv } from "yargs"
 import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2"
 import { Session } from "../../session"
-import { MessageV2 } from "../../session/message-v2"
+import { MessageV2 } from "../../session"
 import { cmd } from "./cmd"
 import { bootstrap } from "../bootstrap"
 import { Database } from "../../storage/db"

+ 1 - 1
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -85,7 +85,7 @@ import { useTuiConfig } from "../../context/tui-config"
 import { getScrollAcceleration } from "../../util/scroll"
 import { TuiPluginRuntime } from "../../plugin"
 import { DialogGoUpsell } from "../../component/dialog-go-upsell"
-import { SessionRetry } from "@/session/retry"
+import { SessionRetry } from "@/session"
 
 addDefaultParsers(parsers.parsers)
 

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

@@ -22,17 +22,17 @@ import { Skill } from "@/skill"
 import { Discovery } from "@/skill/discovery"
 import { Question } from "@/question"
 import { Permission } from "@/permission"
-import { Todo } from "@/session/todo"
+import { Todo } from "@/session"
 import { Session } from "@/session"
-import { SessionStatus } from "@/session/status"
-import { SessionRunState } from "@/session/run-state"
-import { SessionProcessor } from "@/session/processor"
-import { SessionCompaction } from "@/session/compaction"
-import { SessionRevert } from "@/session/revert"
-import { SessionSummary } from "@/session/summary"
-import { SessionPrompt } from "@/session/prompt"
-import { Instruction } from "@/session/instruction"
-import { LLM } from "@/session/llm"
+import { SessionStatus } from "@/session"
+import { SessionRunState } from "@/session"
+import { SessionProcessor } from "@/session"
+import { SessionCompaction } from "@/session"
+import { SessionRevert } from "@/session"
+import { SessionSummary } from "@/session"
+import { SessionPrompt } from "@/session"
+import { Instruction } from "@/session"
+import { LLM } from "@/session"
 import { LSP } from "@/lsp"
 import { MCP } from "@/mcp"
 import { McpAuth } from "@/mcp/auth"

+ 1 - 1
packages/opencode/src/plugin/github-copilot/copilot.ts

@@ -5,7 +5,7 @@ import { iife } from "@/util/iife"
 import { Log } from "../../util/log"
 import { setTimeout as sleep } from "node:timers/promises"
 import { CopilotModels } from "./models"
-import { MessageV2 } from "@/session/message-v2"
+import { MessageV2 } from "@/session"
 
 const log = Log.create({ service: "plugin.copilot" })
 

+ 8 - 8
packages/opencode/src/server/instance/session.ts

@@ -4,15 +4,15 @@ import { describeRoute, validator, resolver } from "hono-openapi"
 import { SessionID, MessageID, PartID } from "@/session/schema"
 import z from "zod"
 import { Session } from "../../session"
-import { MessageV2 } from "../../session/message-v2"
-import { SessionPrompt } from "../../session/prompt"
-import { SessionRunState } from "@/session/run-state"
-import { SessionCompaction } from "../../session/compaction"
-import { SessionRevert } from "../../session/revert"
+import { MessageV2 } from "../../session"
+import { SessionPrompt } from "../../session"
+import { SessionRunState } from "@/session"
+import { SessionCompaction } from "../../session"
+import { SessionRevert } from "../../session"
 import { SessionShare } from "@/share/session"
-import { SessionStatus } from "@/session/status"
-import { SessionSummary } from "@/session/summary"
-import { Todo } from "../../session/todo"
+import { SessionStatus } from "@/session"
+import { SessionSummary } from "@/session"
+import { Todo } from "../../session"
 import { Effect } from "effect"
 import { AppRuntime } from "../../effect/app-runtime"
 import { Agent } from "../../agent/agent"

+ 335 - 337
packages/opencode/src/session/compaction.ts

@@ -3,11 +3,11 @@ import { Bus } from "@/bus"
 import { Session } from "."
 import { SessionID, MessageID, PartID } from "./schema"
 import { Provider } from "../provider"
-import { MessageV2 } from "./message-v2"
+import { MessageV2 } from "."
 import z from "zod"
 import { Token } from "../util/token"
 import { Log } from "../util/log"
-import { SessionProcessor } from "./processor"
+import { SessionProcessor } from "."
 import { Agent } from "@/agent/agent"
 import { Plugin } from "@/plugin"
 import { Config } from "@/config"
@@ -17,173 +17,172 @@ import { Effect, Layer, Context } from "effect"
 import { InstanceState } from "@/effect"
 import { isOverflow as overflow } from "./overflow"
 
-export namespace SessionCompaction {
-  const log = Log.create({ service: "session.compaction" })
+const log = Log.create({ service: "session.compaction" })
 
-  export const Event = {
-    Compacted: BusEvent.define(
-      "session.compacted",
-      z.object({
-        sessionID: SessionID.zod,
-      }),
-    ),
-  }
+export const Event = {
+  Compacted: BusEvent.define(
+    "session.compacted",
+    z.object({
+      sessionID: SessionID.zod,
+    }),
+  ),
+}
 
-  export const PRUNE_MINIMUM = 20_000
-  export const PRUNE_PROTECT = 40_000
-  const PRUNE_PROTECTED_TOOLS = ["skill"]
+export const PRUNE_MINIMUM = 20_000
+export const PRUNE_PROTECT = 40_000
+const PRUNE_PROTECTED_TOOLS = ["skill"]
+
+export interface Interface {
+  readonly isOverflow: (input: {
+    tokens: MessageV2.Assistant["tokens"]
+    model: Provider.Model
+  }) => Effect.Effect<boolean>
+  readonly prune: (input: { sessionID: SessionID }) => Effect.Effect<void>
+  readonly process: (input: {
+    parentID: MessageID
+    messages: MessageV2.WithParts[]
+    sessionID: SessionID
+    auto: boolean
+    overflow?: boolean
+  }) => Effect.Effect<"continue" | "stop">
+  readonly create: (input: {
+    sessionID: SessionID
+    agent: string
+    model: { providerID: ProviderID; modelID: ModelID }
+    auto: boolean
+    overflow?: boolean
+  }) => Effect.Effect<void>
+}
 
-  export interface Interface {
-    readonly isOverflow: (input: {
+export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
+
+export const layer: Layer.Layer<
+  Service,
+  never,
+  | Bus.Service
+  | Config.Service
+  | Session.Service
+  | Agent.Service
+  | Plugin.Service
+  | SessionProcessor.Service
+  | Provider.Service
+> = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const bus = yield* Bus.Service
+    const config = yield* Config.Service
+    const session = yield* Session.Service
+    const agents = yield* Agent.Service
+    const plugin = yield* Plugin.Service
+    const processors = yield* SessionProcessor.Service
+    const provider = yield* Provider.Service
+
+    const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: {
       tokens: MessageV2.Assistant["tokens"]
       model: Provider.Model
-    }) => Effect.Effect<boolean>
-    readonly prune: (input: { sessionID: SessionID }) => Effect.Effect<void>
-    readonly process: (input: {
+    }) {
+      return overflow({ cfg: yield* config.get(), tokens: input.tokens, model: input.model })
+    })
+
+    // goes backwards through parts until there are PRUNE_PROTECT tokens worth of tool
+    // calls, then erases output of older tool calls to free context space
+    const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) {
+      const cfg = yield* config.get()
+      if (cfg.compaction?.prune === false) return
+      log.info("pruning")
+
+      const msgs = yield* session
+        .messages({ sessionID: input.sessionID })
+        .pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)))
+      if (!msgs) return
+
+      let total = 0
+      let pruned = 0
+      const toPrune: MessageV2.ToolPart[] = []
+      let turns = 0
+
+      loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
+        const msg = msgs[msgIndex]
+        if (msg.info.role === "user") turns++
+        if (turns < 2) continue
+        if (msg.info.role === "assistant" && msg.info.summary) break loop
+        for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
+          const part = msg.parts[partIndex]
+          if (part.type === "tool")
+            if (part.state.status === "completed") {
+              if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
+              if (part.state.time.compacted) break loop
+              const estimate = Token.estimate(part.state.output)
+              total += estimate
+              if (total > PRUNE_PROTECT) {
+                pruned += estimate
+                toPrune.push(part)
+              }
+            }
+        }
+      }
+
+      log.info("found", { pruned, total })
+      if (pruned > PRUNE_MINIMUM) {
+        for (const part of toPrune) {
+          if (part.state.status === "completed") {
+            part.state.time.compacted = Date.now()
+            yield* session.updatePart(part)
+          }
+        }
+        log.info("pruned", { count: toPrune.length })
+      }
+    })
+
+    const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: {
       parentID: MessageID
       messages: MessageV2.WithParts[]
       sessionID: SessionID
       auto: boolean
       overflow?: boolean
-    }) => Effect.Effect<"continue" | "stop">
-    readonly create: (input: {
-      sessionID: SessionID
-      agent: string
-      model: { providerID: ProviderID; modelID: ModelID }
-      auto: boolean
-      overflow?: boolean
-    }) => Effect.Effect<void>
-  }
-
-  export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
-
-  export const layer: Layer.Layer<
-    Service,
-    never,
-    | Bus.Service
-    | Config.Service
-    | Session.Service
-    | Agent.Service
-    | Plugin.Service
-    | SessionProcessor.Service
-    | Provider.Service
-  > = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const bus = yield* Bus.Service
-      const config = yield* Config.Service
-      const session = yield* Session.Service
-      const agents = yield* Agent.Service
-      const plugin = yield* Plugin.Service
-      const processors = yield* SessionProcessor.Service
-      const provider = yield* Provider.Service
-
-      const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: {
-        tokens: MessageV2.Assistant["tokens"]
-        model: Provider.Model
-      }) {
-        return overflow({ cfg: yield* config.get(), tokens: input.tokens, model: input.model })
-      })
-
-      // goes backwards through parts until there are PRUNE_PROTECT tokens worth of tool
-      // calls, then erases output of older tool calls to free context space
-      const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) {
-        const cfg = yield* config.get()
-        if (cfg.compaction?.prune === false) return
-        log.info("pruning")
-
-        const msgs = yield* session
-          .messages({ sessionID: input.sessionID })
-          .pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)))
-        if (!msgs) return
-
-        let total = 0
-        let pruned = 0
-        const toPrune: MessageV2.ToolPart[] = []
-        let turns = 0
-
-        loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
-          const msg = msgs[msgIndex]
-          if (msg.info.role === "user") turns++
-          if (turns < 2) continue
-          if (msg.info.role === "assistant" && msg.info.summary) break loop
-          for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
-            const part = msg.parts[partIndex]
-            if (part.type === "tool")
-              if (part.state.status === "completed") {
-                if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
-                if (part.state.time.compacted) break loop
-                const estimate = Token.estimate(part.state.output)
-                total += estimate
-                if (total > PRUNE_PROTECT) {
-                  pruned += estimate
-                  toPrune.push(part)
-                }
-              }
+    }) {
+      const parent = input.messages.findLast((m) => m.info.id === input.parentID)
+      if (!parent || parent.info.role !== "user") {
+        throw new Error(`Compaction parent must be a user message: ${input.parentID}`)
+      }
+      const userMessage = parent.info
+
+      let messages = input.messages
+      let replay:
+        | {
+            info: MessageV2.User
+            parts: MessageV2.Part[]
           }
-        }
-
-        log.info("found", { pruned, total })
-        if (pruned > PRUNE_MINIMUM) {
-          for (const part of toPrune) {
-            if (part.state.status === "completed") {
-              part.state.time.compacted = Date.now()
-              yield* session.updatePart(part)
-            }
+        | undefined
+      if (input.overflow) {
+        const idx = input.messages.findIndex((m) => m.info.id === input.parentID)
+        for (let i = idx - 1; i >= 0; i--) {
+          const msg = input.messages[i]
+          if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) {
+            replay = { info: msg.info, parts: msg.parts }
+            messages = input.messages.slice(0, i)
+            break
           }
-          log.info("pruned", { count: toPrune.length })
-        }
-      })
-
-      const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: {
-        parentID: MessageID
-        messages: MessageV2.WithParts[]
-        sessionID: SessionID
-        auto: boolean
-        overflow?: boolean
-      }) {
-        const parent = input.messages.findLast((m) => m.info.id === input.parentID)
-        if (!parent || parent.info.role !== "user") {
-          throw new Error(`Compaction parent must be a user message: ${input.parentID}`)
         }
-        const userMessage = parent.info
-
-        let messages = input.messages
-        let replay:
-          | {
-              info: MessageV2.User
-              parts: MessageV2.Part[]
-            }
-          | undefined
-        if (input.overflow) {
-          const idx = input.messages.findIndex((m) => m.info.id === input.parentID)
-          for (let i = idx - 1; i >= 0; i--) {
-            const msg = input.messages[i]
-            if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) {
-              replay = { info: msg.info, parts: msg.parts }
-              messages = input.messages.slice(0, i)
-              break
-            }
-          }
-          const hasContent =
-            replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"))
-          if (!hasContent) {
-            replay = undefined
-            messages = input.messages
-          }
+        const hasContent =
+          replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"))
+        if (!hasContent) {
+          replay = undefined
+          messages = input.messages
         }
-
-        const agent = yield* agents.get("compaction")
-        const model = agent.model
-          ? yield* provider.getModel(agent.model.providerID, agent.model.modelID)
-          : yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
-        // Allow plugins to inject context or replace compaction prompt.
-        const compacting = yield* plugin.trigger(
-          "experimental.session.compacting",
-          { sessionID: input.sessionID },
-          { context: [], prompt: undefined },
-        )
-        const defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
+      }
+
+      const agent = yield* agents.get("compaction")
+      const model = agent.model
+        ? yield* provider.getModel(agent.model.providerID, agent.model.modelID)
+        : yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
+      // Allow plugins to inject context or replace compaction prompt.
+      const compacting = yield* plugin.trigger(
+        "experimental.session.compacting",
+        { sessionID: input.sessionID },
+        { context: [], prompt: undefined },
+      )
+      const defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
 Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.
 The summary that you construct will be used so that another agent can read it and continue the work.
 Do not call any tools. Respond only with the summary text.
@@ -213,200 +212,199 @@ When constructing the summary, try to stick to this template:
 [Construct a structured list of relevant files that have been read, edited, or created that pertain to the task at hand. If all the files in a directory are relevant, include the path to the directory.]
 ---`
 
-        const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
-        const msgs = structuredClone(messages)
-        yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
-        const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true })
-        const ctx = yield* InstanceState.context
-        const msg: MessageV2.Assistant = {
-          id: MessageID.ascending(),
-          role: "assistant",
-          parentID: input.parentID,
-          sessionID: input.sessionID,
-          mode: "compaction",
-          agent: "compaction",
-          variant: userMessage.model.variant,
-          summary: true,
-          path: {
-            cwd: ctx.directory,
-            root: ctx.worktree,
-          },
-          cost: 0,
-          tokens: {
-            output: 0,
-            input: 0,
-            reasoning: 0,
-            cache: { read: 0, write: 0 },
-          },
-          modelID: model.id,
-          providerID: model.providerID,
-          time: {
-            created: Date.now(),
+      const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
+      const msgs = structuredClone(messages)
+      yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
+      const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true })
+      const ctx = yield* InstanceState.context
+      const msg: MessageV2.Assistant = {
+        id: MessageID.ascending(),
+        role: "assistant",
+        parentID: input.parentID,
+        sessionID: input.sessionID,
+        mode: "compaction",
+        agent: "compaction",
+        variant: userMessage.model.variant,
+        summary: true,
+        path: {
+          cwd: ctx.directory,
+          root: ctx.worktree,
+        },
+        cost: 0,
+        tokens: {
+          output: 0,
+          input: 0,
+          reasoning: 0,
+          cache: { read: 0, write: 0 },
+        },
+        modelID: model.id,
+        providerID: model.providerID,
+        time: {
+          created: Date.now(),
+        },
+      }
+      yield* session.updateMessage(msg)
+      const processor = yield* processors.create({
+        assistantMessage: msg,
+        sessionID: input.sessionID,
+        model,
+      })
+      const result = yield* processor.process({
+        user: userMessage,
+        agent,
+        sessionID: input.sessionID,
+        tools: {},
+        system: [],
+        messages: [
+          ...modelMessages,
+          {
+            role: "user",
+            content: [{ type: "text", text: prompt }],
           },
-        }
-        yield* session.updateMessage(msg)
-        const processor = yield* processors.create({
-          assistantMessage: msg,
-          sessionID: input.sessionID,
-          model,
-        })
-        const result = yield* processor.process({
-          user: userMessage,
-          agent,
-          sessionID: input.sessionID,
-          tools: {},
-          system: [],
-          messages: [
-            ...modelMessages,
-            {
-              role: "user",
-              content: [{ type: "text", text: prompt }],
-            },
-          ],
-          model,
-        })
-
-        if (result === "compact") {
-          processor.message.error = new MessageV2.ContextOverflowError({
-            message: replay
-              ? "Conversation history too large to compact - exceeds model context limit"
-              : "Session too large to compact - context exceeds model limit even after stripping media",
-          }).toObject()
-          processor.message.finish = "error"
-          yield* session.updateMessage(processor.message)
-          return "stop"
-        }
+        ],
+        model,
+      })
 
-        if (result === "continue" && input.auto) {
-          if (replay) {
-            const original = replay.info
-            const replayMsg = yield* session.updateMessage({
-              id: MessageID.ascending(),
-              role: "user",
+      if (result === "compact") {
+        processor.message.error = new MessageV2.ContextOverflowError({
+          message: replay
+            ? "Conversation history too large to compact - exceeds model context limit"
+            : "Session too large to compact - context exceeds model limit even after stripping media",
+        }).toObject()
+        processor.message.finish = "error"
+        yield* session.updateMessage(processor.message)
+        return "stop"
+      }
+
+      if (result === "continue" && input.auto) {
+        if (replay) {
+          const original = replay.info
+          const replayMsg = yield* session.updateMessage({
+            id: MessageID.ascending(),
+            role: "user",
+            sessionID: input.sessionID,
+            time: { created: Date.now() },
+            agent: original.agent,
+            model: original.model,
+            format: original.format,
+            tools: original.tools,
+            system: original.system,
+          })
+          for (const part of replay.parts) {
+            if (part.type === "compaction") continue
+            const replayPart =
+              part.type === "file" && MessageV2.isMedia(part.mime)
+                ? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` }
+                : part
+            yield* session.updatePart({
+              ...replayPart,
+              id: PartID.ascending(),
+              messageID: replayMsg.id,
               sessionID: input.sessionID,
-              time: { created: Date.now() },
-              agent: original.agent,
-              model: original.model,
-              format: original.format,
-              tools: original.tools,
-              system: original.system,
             })
-            for (const part of replay.parts) {
-              if (part.type === "compaction") continue
-              const replayPart =
-                part.type === "file" && MessageV2.isMedia(part.mime)
-                  ? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` }
-                  : part
-              yield* session.updatePart({
-                ...replayPart,
-                id: PartID.ascending(),
-                messageID: replayMsg.id,
-                sessionID: input.sessionID,
-              })
-            }
           }
+        }
 
-          if (!replay) {
-            const info = yield* provider.getProvider(userMessage.model.providerID)
-            if (
-              (yield* plugin.trigger(
-                "experimental.compaction.autocontinue",
-                {
-                  sessionID: input.sessionID,
-                  agent: userMessage.agent,
-                  model: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
-                  provider: {
-                    source: info.source,
-                    info,
-                    options: info.options,
-                  },
-                  message: userMessage,
-                  overflow: input.overflow === true,
-                },
-                { enabled: true },
-              )).enabled
-            ) {
-              const continueMsg = yield* session.updateMessage({
-                id: MessageID.ascending(),
-                role: "user",
+        if (!replay) {
+          const info = yield* provider.getProvider(userMessage.model.providerID)
+          if (
+            (yield* plugin.trigger(
+              "experimental.compaction.autocontinue",
+              {
                 sessionID: input.sessionID,
-                time: { created: Date.now() },
                 agent: userMessage.agent,
-                model: userMessage.model,
-              })
-              const text =
-                (input.overflow
-                  ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
-                  : "") +
-                "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
-              yield* session.updatePart({
-                id: PartID.ascending(),
-                messageID: continueMsg.id,
-                sessionID: input.sessionID,
-                type: "text",
-                // Internal marker for auto-compaction followups so provider plugins
-                // can distinguish them from manual post-compaction user prompts.
-                // This is not a stable plugin contract and may change or disappear.
-                metadata: { compaction_continue: true },
-                synthetic: true,
-                text,
-                time: {
-                  start: Date.now(),
-                  end: Date.now(),
+                model: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
+                provider: {
+                  source: info.source,
+                  info,
+                  options: info.options,
                 },
-              })
-            }
+                message: userMessage,
+                overflow: input.overflow === true,
+              },
+              { enabled: true },
+            )).enabled
+          ) {
+            const continueMsg = yield* session.updateMessage({
+              id: MessageID.ascending(),
+              role: "user",
+              sessionID: input.sessionID,
+              time: { created: Date.now() },
+              agent: userMessage.agent,
+              model: userMessage.model,
+            })
+            const text =
+              (input.overflow
+                ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
+                : "") +
+              "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
+            yield* session.updatePart({
+              id: PartID.ascending(),
+              messageID: continueMsg.id,
+              sessionID: input.sessionID,
+              type: "text",
+              // Internal marker for auto-compaction followups so provider plugins
+              // can distinguish them from manual post-compaction user prompts.
+              // This is not a stable plugin contract and may change or disappear.
+              metadata: { compaction_continue: true },
+              synthetic: true,
+              text,
+              time: {
+                start: Date.now(),
+                end: Date.now(),
+              },
+            })
           }
         }
+      }
 
-        if (processor.message.error) return "stop"
-        if (result === "continue") yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
-        return result
-      })
+      if (processor.message.error) return "stop"
+      if (result === "continue") yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
+      return result
+    })
 
-      const create = Effect.fn("SessionCompaction.create")(function* (input: {
-        sessionID: SessionID
-        agent: string
-        model: { providerID: ProviderID; modelID: ModelID }
-        auto: boolean
-        overflow?: boolean
-      }) {
-        const msg = yield* session.updateMessage({
-          id: MessageID.ascending(),
-          role: "user",
-          model: input.model,
-          sessionID: input.sessionID,
-          agent: input.agent,
-          time: { created: Date.now() },
-        })
-        yield* session.updatePart({
-          id: PartID.ascending(),
-          messageID: msg.id,
-          sessionID: msg.sessionID,
-          type: "compaction",
-          auto: input.auto,
-          overflow: input.overflow,
-        })
+    const create = Effect.fn("SessionCompaction.create")(function* (input: {
+      sessionID: SessionID
+      agent: string
+      model: { providerID: ProviderID; modelID: ModelID }
+      auto: boolean
+      overflow?: boolean
+    }) {
+      const msg = yield* session.updateMessage({
+        id: MessageID.ascending(),
+        role: "user",
+        model: input.model,
+        sessionID: input.sessionID,
+        agent: input.agent,
+        time: { created: Date.now() },
       })
-
-      return Service.of({
-        isOverflow,
-        prune,
-        process: processCompaction,
-        create,
+      yield* session.updatePart({
+        id: PartID.ascending(),
+        messageID: msg.id,
+        sessionID: msg.sessionID,
+        type: "compaction",
+        auto: input.auto,
+        overflow: input.overflow,
       })
-    }),
-  )
-
-  export const defaultLayer = Layer.suspend(() =>
-    layer.pipe(
-      Layer.provide(Provider.defaultLayer),
-      Layer.provide(Session.defaultLayer),
-      Layer.provide(SessionProcessor.defaultLayer),
-      Layer.provide(Agent.defaultLayer),
-      Layer.provide(Plugin.defaultLayer),
-      Layer.provide(Bus.layer),
-      Layer.provide(Config.defaultLayer),
-    ),
-  )
-}
+    })
+
+    return Service.of({
+      isOverflow,
+      prune,
+      process: processCompaction,
+      create,
+    })
+  }),
+)
+
+export const defaultLayer = Layer.suspend(() =>
+  layer.pipe(
+    Layer.provide(Provider.defaultLayer),
+    Layer.provide(Session.defaultLayer),
+    Layer.provide(SessionProcessor.defaultLayer),
+    Layer.provide(Agent.defaultLayer),
+    Layer.provide(Plugin.defaultLayer),
+    Layer.provide(Bus.layer),
+    Layer.provide(Config.defaultLayer),
+  ),
+)

+ 14 - 0
packages/opencode/src/session/index.ts

@@ -1 +1,15 @@
 export * as Session from "./session"
+export * as SessionRunState from "./run-state"
+export * as SystemPrompt from "./system"
+export * as Message from "./message"
+export * as SessionRetry from "./retry"
+export * as SessionProcessor from "./processor"
+export * as SessionRevert from "./revert"
+export * as Instruction from "./instruction"
+export * as SessionSummary from "./summary"
+export * as Todo from "./todo"
+export * as LLM from "./llm"
+export * as SessionStatus from "./status"
+export * as SessionCompaction from "./compaction"
+export * as SessionPrompt from "./prompt"
+export * as MessageV2 from "./message-v2"

+ 165 - 167
packages/opencode/src/session/instruction.ts

@@ -10,7 +10,7 @@ import { withTransientReadRetry } from "@/util/effect-http-client"
 import { Global } from "../global"
 import { Instance } from "../project/instance"
 import { Log } from "../util/log"
-import type { MessageV2 } from "./message-v2"
+import type { MessageV2 } from "."
 import type { MessageID } from "./schema"
 
 const log = Log.create({ service: "instruction" })
@@ -50,194 +50,192 @@ function extract(messages: MessageV2.WithParts[]) {
   return paths
 }
 
-export namespace Instruction {
-  export interface Interface {
-    readonly clear: (messageID: MessageID) => Effect.Effect<void>
-    readonly systemPaths: () => Effect.Effect<Set<string>, AppFileSystem.Error>
-    readonly system: () => Effect.Effect<string[], AppFileSystem.Error>
-    readonly find: (dir: string) => Effect.Effect<string | undefined, AppFileSystem.Error>
-    readonly resolve: (
-      messages: MessageV2.WithParts[],
-      filepath: string,
-      messageID: MessageID,
-    ) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error>
-  }
-
-  export class Service extends Context.Service<Service, Interface>()("@opencode/Instruction") {}
-
-  export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.Service | HttpClient.HttpClient> =
-    Layer.effect(
-      Service,
-      Effect.gen(function* () {
-        const cfg = yield* Config.Service
-        const fs = yield* AppFileSystem.Service
-        const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
-
-        const state = yield* InstanceState.make(
-          Effect.fn("Instruction.state")(() =>
-            Effect.succeed({
-              // Track which instruction files have already been attached for a given assistant message.
-              claims: new Map<MessageID, Set<string>>(),
-            }),
-          ),
-        )
+export interface Interface {
+  readonly clear: (messageID: MessageID) => Effect.Effect<void>
+  readonly systemPaths: () => Effect.Effect<Set<string>, AppFileSystem.Error>
+  readonly system: () => Effect.Effect<string[], AppFileSystem.Error>
+  readonly find: (dir: string) => Effect.Effect<string | undefined, AppFileSystem.Error>
+  readonly resolve: (
+    messages: MessageV2.WithParts[],
+    filepath: string,
+    messageID: MessageID,
+  ) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error>
+}
 
-        const relative = Effect.fnUntraced(function* (instruction: string) {
-          if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
-            return yield* fs
-              .globUp(instruction, Instance.directory, Instance.worktree)
-              .pipe(Effect.catch(() => Effect.succeed([] as string[])))
-          }
-          if (!Flag.OPENCODE_CONFIG_DIR) {
-            log.warn(
-              `Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
-            )
-            return []
-          }
+export class Service extends Context.Service<Service, Interface>()("@opencode/Instruction") {}
+
+export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.Service | HttpClient.HttpClient> =
+  Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const cfg = yield* Config.Service
+      const fs = yield* AppFileSystem.Service
+      const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
+
+      const state = yield* InstanceState.make(
+        Effect.fn("Instruction.state")(() =>
+          Effect.succeed({
+            // Track which instruction files have already been attached for a given assistant message.
+            claims: new Map<MessageID, Set<string>>(),
+          }),
+        ),
+      )
+
+      const relative = Effect.fnUntraced(function* (instruction: string) {
+        if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
           return yield* fs
-            .globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR)
+            .globUp(instruction, Instance.directory, Instance.worktree)
             .pipe(Effect.catch(() => Effect.succeed([] as string[])))
-        })
-
-        const read = Effect.fnUntraced(function* (filepath: string) {
-          return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed("")))
-        })
-
-        const fetch = Effect.fnUntraced(function* (url: string) {
-          const res = yield* http.execute(HttpClientRequest.get(url)).pipe(
-            Effect.timeout(5000),
-            Effect.catch(() => Effect.succeed(null)),
+        }
+        if (!Flag.OPENCODE_CONFIG_DIR) {
+          log.warn(
+            `Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
           )
-          if (!res) return ""
-          const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0))))
-          return new TextDecoder().decode(body)
-        })
-
-        const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
-          const s = yield* InstanceState.get(state)
-          s.claims.delete(messageID)
-        })
-
-        const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
-          const config = yield* cfg.get()
-          const paths = new Set<string>()
-
-          // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
-          if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
-            for (const file of FILES) {
-              const matches = yield* fs.findUp(file, Instance.directory, Instance.worktree)
-              if (matches.length > 0) {
-                matches.forEach((item) => paths.add(path.resolve(item)))
-                break
-              }
+          return []
+        }
+        return yield* fs
+          .globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR)
+          .pipe(Effect.catch(() => Effect.succeed([] as string[])))
+      })
+
+      const read = Effect.fnUntraced(function* (filepath: string) {
+        return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed("")))
+      })
+
+      const fetch = Effect.fnUntraced(function* (url: string) {
+        const res = yield* http.execute(HttpClientRequest.get(url)).pipe(
+          Effect.timeout(5000),
+          Effect.catch(() => Effect.succeed(null)),
+        )
+        if (!res) return ""
+        const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0))))
+        return new TextDecoder().decode(body)
+      })
+
+      const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
+        const s = yield* InstanceState.get(state)
+        s.claims.delete(messageID)
+      })
+
+      const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
+        const config = yield* cfg.get()
+        const paths = new Set<string>()
+
+        // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
+        if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
+          for (const file of FILES) {
+            const matches = yield* fs.findUp(file, Instance.directory, Instance.worktree)
+            if (matches.length > 0) {
+              matches.forEach((item) => paths.add(path.resolve(item)))
+              break
             }
           }
+        }
 
-          for (const file of globalFiles()) {
-            if (yield* fs.existsSafe(file)) {
-              paths.add(path.resolve(file))
-              break
-            }
+        for (const file of globalFiles()) {
+          if (yield* fs.existsSafe(file)) {
+            paths.add(path.resolve(file))
+            break
           }
+        }
 
-          if (config.instructions) {
-            for (const raw of config.instructions) {
-              if (raw.startsWith("https://") || raw.startsWith("http://")) continue
-              const instruction = raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw
-              const matches = yield* (
-                path.isAbsolute(instruction)
-                  ? fs.glob(path.basename(instruction), {
-                      cwd: path.dirname(instruction),
-                      absolute: true,
-                      include: "file",
-                    })
-                  : relative(instruction)
-              ).pipe(Effect.catch(() => Effect.succeed([] as string[])))
-              matches.forEach((item) => paths.add(path.resolve(item)))
-            }
+        if (config.instructions) {
+          for (const raw of config.instructions) {
+            if (raw.startsWith("https://") || raw.startsWith("http://")) continue
+            const instruction = raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw
+            const matches = yield* (
+              path.isAbsolute(instruction)
+                ? fs.glob(path.basename(instruction), {
+                    cwd: path.dirname(instruction),
+                    absolute: true,
+                    include: "file",
+                  })
+                : relative(instruction)
+            ).pipe(Effect.catch(() => Effect.succeed([] as string[])))
+            matches.forEach((item) => paths.add(path.resolve(item)))
           }
+        }
 
-          return paths
-        })
+        return paths
+      })
 
-        const system = Effect.fn("Instruction.system")(function* () {
-          const config = yield* cfg.get()
-          const paths = yield* systemPaths()
-          const urls = (config.instructions ?? []).filter(
-            (item) => item.startsWith("https://") || item.startsWith("http://"),
-          )
+      const system = Effect.fn("Instruction.system")(function* () {
+        const config = yield* cfg.get()
+        const paths = yield* systemPaths()
+        const urls = (config.instructions ?? []).filter(
+          (item) => item.startsWith("https://") || item.startsWith("http://"),
+        )
 
-          const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
-          const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })
+        const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
+        const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })
 
-          return [
-            ...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])),
-            ...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
-          ]
-        })
+        return [
+          ...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])),
+          ...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
+        ]
+      })
 
-        const find = Effect.fn("Instruction.find")(function* (dir: string) {
-          for (const file of FILES) {
-            const filepath = path.resolve(path.join(dir, file))
-            if (yield* fs.existsSafe(filepath)) return filepath
+      const find = Effect.fn("Instruction.find")(function* (dir: string) {
+        for (const file of FILES) {
+          const filepath = path.resolve(path.join(dir, file))
+          if (yield* fs.existsSafe(filepath)) return filepath
+        }
+      })
+
+      const resolve = Effect.fn("Instruction.resolve")(function* (
+        messages: MessageV2.WithParts[],
+        filepath: string,
+        messageID: MessageID,
+      ) {
+        const sys = yield* systemPaths()
+        const already = extract(messages)
+        const results: { filepath: string; content: string }[] = []
+        const s = yield* InstanceState.get(state)
+
+        const target = path.resolve(filepath)
+        const root = path.resolve(Instance.directory)
+        let current = path.dirname(target)
+
+        // Walk upward from the file being read and attach nearby instruction files once per message.
+        while (current.startsWith(root) && current !== root) {
+          const found = yield* find(current)
+          if (!found || found === target || sys.has(found) || already.has(found)) {
+            current = path.dirname(current)
+            continue
           }
-        })
-
-        const resolve = Effect.fn("Instruction.resolve")(function* (
-          messages: MessageV2.WithParts[],
-          filepath: string,
-          messageID: MessageID,
-        ) {
-          const sys = yield* systemPaths()
-          const already = extract(messages)
-          const results: { filepath: string; content: string }[] = []
-          const s = yield* InstanceState.get(state)
-
-          const target = path.resolve(filepath)
-          const root = path.resolve(Instance.directory)
-          let current = path.dirname(target)
-
-          // Walk upward from the file being read and attach nearby instruction files once per message.
-          while (current.startsWith(root) && current !== root) {
-            const found = yield* find(current)
-            if (!found || found === target || sys.has(found) || already.has(found)) {
-              current = path.dirname(current)
-              continue
-            }
-
-            let set = s.claims.get(messageID)
-            if (!set) {
-              set = new Set()
-              s.claims.set(messageID, set)
-            }
-            if (set.has(found)) {
-              current = path.dirname(current)
-              continue
-            }
-
-            set.add(found)
-            const content = yield* read(found)
-            if (content) {
-              results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
-            }
 
+          let set = s.claims.get(messageID)
+          if (!set) {
+            set = new Set()
+            s.claims.set(messageID, set)
+          }
+          if (set.has(found)) {
             current = path.dirname(current)
+            continue
           }
 
-          return results
-        })
+          set.add(found)
+          const content = yield* read(found)
+          if (content) {
+            results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
+          }
 
-        return Service.of({ clear, systemPaths, system, find, resolve })
-      }),
-    )
+          current = path.dirname(current)
+        }
+
+        return results
+      })
 
-  export const defaultLayer = layer.pipe(
-    Layer.provide(Config.defaultLayer),
-    Layer.provide(AppFileSystem.defaultLayer),
-    Layer.provide(FetchHttpClient.layer),
+      return Service.of({ clear, systemPaths, system, find, resolve })
+    }),
   )
 
-  export function loaded(messages: MessageV2.WithParts[]) {
-    return extract(messages)
-  }
+export const defaultLayer = layer.pipe(
+  Layer.provide(Config.defaultLayer),
+  Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(FetchHttpClient.layer),
+)
+
+export function loaded(messages: MessageV2.WithParts[]) {
+  return extract(messages)
 }

+ 392 - 394
packages/opencode/src/session/llm.ts

@@ -9,9 +9,9 @@ import { ProviderTransform } from "@/provider/transform"
 import { Config } from "@/config"
 import { Instance } from "@/project/instance"
 import type { Agent } from "@/agent/agent"
-import type { MessageV2 } from "./message-v2"
+import type { MessageV2 } from "."
 import { Plugin } from "@/plugin"
-import { SystemPrompt } from "./system"
+import { SystemPrompt } from "."
 import { Flag } from "@/flag/flag"
 import { Permission } from "@/permission"
 import { PermissionID } from "@/permission/schema"
@@ -24,430 +24,428 @@ import { EffectBridge } from "@/effect"
 import * as Option from "effect/Option"
 import * as OtelTracer from "@effect/opentelemetry/Tracer"
 
-export namespace LLM {
-  const log = Log.create({ service: "llm" })
-  export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
-  type Result = Awaited<ReturnType<typeof streamText>>
-
-  export type StreamInput = {
-    user: MessageV2.User
-    sessionID: string
-    parentSessionID?: string
-    model: Provider.Model
-    agent: Agent.Info
-    permission?: Permission.Ruleset
-    system: string[]
-    messages: ModelMessage[]
-    small?: boolean
-    tools: Record<string, Tool>
-    retries?: number
-    toolChoice?: "auto" | "required" | "none"
-  }
-
-  export type StreamRequest = StreamInput & {
-    abort: AbortSignal
-  }
+const log = Log.create({ service: "llm" })
+export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
+type Result = Awaited<ReturnType<typeof streamText>>
+
+export type StreamInput = {
+  user: MessageV2.User
+  sessionID: string
+  parentSessionID?: string
+  model: Provider.Model
+  agent: Agent.Info
+  permission?: Permission.Ruleset
+  system: string[]
+  messages: ModelMessage[]
+  small?: boolean
+  tools: Record<string, Tool>
+  retries?: number
+  toolChoice?: "auto" | "required" | "none"
+}
 
-  export type Event = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
+export type StreamRequest = StreamInput & {
+  abort: AbortSignal
+}
 
-  export interface Interface {
-    readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
-  }
+export type Event = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
 
-  export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
-
-  const live: Layer.Layer<
-    Service,
-    never,
-    Auth.Service | Config.Service | Provider.Service | Plugin.Service | Permission.Service
-  > = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const auth = yield* Auth.Service
-      const config = yield* Config.Service
-      const provider = yield* Provider.Service
-      const plugin = yield* Plugin.Service
-      const perm = yield* Permission.Service
-
-      const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
-        const l = log
-          .clone()
-          .tag("providerID", input.model.providerID)
-          .tag("modelID", input.model.id)
-          .tag("sessionID", input.sessionID)
-          .tag("small", (input.small ?? false).toString())
-          .tag("agent", input.agent.name)
-          .tag("mode", input.agent.mode)
-        l.info("stream", {
-          modelID: input.model.id,
-          providerID: input.model.providerID,
-        })
+export interface Interface {
+  readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
+}
 
-        const [language, cfg, item, info] = yield* Effect.all(
-          [
-            provider.getLanguage(input.model),
-            config.get(),
-            provider.getProvider(input.model.providerID),
-            auth.get(input.model.providerID),
-          ],
-          { concurrency: "unbounded" },
-        )
-
-        // TODO: move this to a proper hook
-        const isOpenaiOauth = item.id === "openai" && info?.type === "oauth"
-
-        const system: string[] = []
-        system.push(
-          [
-            // use agent prompt otherwise provider prompt
-            ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
-            // any custom prompt passed into this call
-            ...input.system,
-            // any custom prompt from last user message
-            ...(input.user.system ? [input.user.system] : []),
-          ]
-            .filter((x) => x)
-            .join("\n"),
-        )
-
-        const header = system[0]
-        yield* plugin.trigger(
-          "experimental.chat.system.transform",
-          { sessionID: input.sessionID, model: input.model },
-          { system },
-        )
-        // rejoin to maintain 2-part structure for caching if header unchanged
-        if (system.length > 2 && system[0] === header) {
-          const rest = system.slice(1)
-          system.length = 0
-          system.push(header, rest.join("\n"))
-        }
+export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
+
+const live: Layer.Layer<
+  Service,
+  never,
+  Auth.Service | Config.Service | Provider.Service | Plugin.Service | Permission.Service
+> = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const auth = yield* Auth.Service
+    const config = yield* Config.Service
+    const provider = yield* Provider.Service
+    const plugin = yield* Plugin.Service
+    const perm = yield* Permission.Service
+
+    const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
+      const l = log
+        .clone()
+        .tag("providerID", input.model.providerID)
+        .tag("modelID", input.model.id)
+        .tag("sessionID", input.sessionID)
+        .tag("small", (input.small ?? false).toString())
+        .tag("agent", input.agent.name)
+        .tag("mode", input.agent.mode)
+      l.info("stream", {
+        modelID: input.model.id,
+        providerID: input.model.providerID,
+      })
 
-        const variant =
-          !input.small && input.model.variants && input.user.model.variant
-            ? input.model.variants[input.user.model.variant]
-            : {}
-        const base = input.small
-          ? ProviderTransform.smallOptions(input.model)
-          : ProviderTransform.options({
-              model: input.model,
-              sessionID: input.sessionID,
-              providerOptions: item.options,
-            })
-        const options: Record<string, any> = pipe(
-          base,
-          mergeDeep(input.model.options),
-          mergeDeep(input.agent.options),
-          mergeDeep(variant),
-        )
-        if (isOpenaiOauth) {
-          options.instructions = system.join("\n")
-        }
+      const [language, cfg, item, info] = yield* Effect.all(
+        [
+          provider.getLanguage(input.model),
+          config.get(),
+          provider.getProvider(input.model.providerID),
+          auth.get(input.model.providerID),
+        ],
+        { concurrency: "unbounded" },
+      )
+
+      // TODO: move this to a proper hook
+      const isOpenaiOauth = item.id === "openai" && info?.type === "oauth"
+
+      const system: string[] = []
+      system.push(
+        [
+          // use agent prompt otherwise provider prompt
+          ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
+          // any custom prompt passed into this call
+          ...input.system,
+          // any custom prompt from last user message
+          ...(input.user.system ? [input.user.system] : []),
+        ]
+          .filter((x) => x)
+          .join("\n"),
+      )
+
+      const header = system[0]
+      yield* plugin.trigger(
+        "experimental.chat.system.transform",
+        { sessionID: input.sessionID, model: input.model },
+        { system },
+      )
+      // rejoin to maintain 2-part structure for caching if header unchanged
+      if (system.length > 2 && system[0] === header) {
+        const rest = system.slice(1)
+        system.length = 0
+        system.push(header, rest.join("\n"))
+      }
 
-        const isWorkflow = language instanceof GitLabWorkflowLanguageModel
-        const messages = isOpenaiOauth
-          ? input.messages
-          : isWorkflow
-            ? input.messages
-            : [
-                ...system.map(
-                  (x): ModelMessage => ({
-                    role: "system",
-                    content: x,
-                  }),
-                ),
-                ...input.messages,
-              ]
-
-        const params = yield* plugin.trigger(
-          "chat.params",
-          {
-            sessionID: input.sessionID,
-            agent: input.agent.name,
+      const variant =
+        !input.small && input.model.variants && input.user.model.variant
+          ? input.model.variants[input.user.model.variant]
+          : {}
+      const base = input.small
+        ? ProviderTransform.smallOptions(input.model)
+        : ProviderTransform.options({
             model: input.model,
-            provider: item,
-            message: input.user,
-          },
-          {
-            temperature: input.model.capabilities.temperature
-              ? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
-              : undefined,
-            topP: input.agent.topP ?? ProviderTransform.topP(input.model),
-            topK: ProviderTransform.topK(input.model),
-            maxOutputTokens: ProviderTransform.maxOutputTokens(input.model),
-            options,
-          },
-        )
-
-        const { headers } = yield* plugin.trigger(
-          "chat.headers",
-          {
             sessionID: input.sessionID,
-            agent: input.agent.name,
-            model: input.model,
-            provider: item,
-            message: input.user,
-          },
-          {
-            headers: {},
-          },
-        )
-
-        const tools = resolveTools(input)
-
-        // LiteLLM and some Anthropic proxies require the tools parameter to be present
-        // when message history contains tool calls, even if no tools are being used.
-        // Add a dummy tool that is never called to satisfy this validation.
-        // This is enabled for:
-        // 1. Providers with "litellm" in their ID or API ID (auto-detected)
-        // 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
-        const isLiteLLMProxy =
-          item.options?.["litellmProxy"] === true ||
-          input.model.providerID.toLowerCase().includes("litellm") ||
-          input.model.api.id.toLowerCase().includes("litellm")
-
-        // LiteLLM/Bedrock rejects requests where the message history contains tool
-        // calls but no tools param is present. When there are no active tools (e.g.
-        // during compaction), inject a stub tool to satisfy the validation requirement.
-        // The stub description explicitly tells the model not to call it.
-        if (
-          (isLiteLLMProxy || input.model.providerID.includes("github-copilot")) &&
-          Object.keys(tools).length === 0 &&
-          hasToolCalls(input.messages)
-        ) {
-          tools["_noop"] = tool({
-            description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
-            inputSchema: jsonSchema({
-              type: "object",
-              properties: {
-                reason: { type: "string", description: "Unused" },
-              },
-            }),
-            execute: async () => ({ output: "", title: "", metadata: {} }),
+            providerOptions: item.options,
           })
-        }
+      const options: Record<string, any> = pipe(
+        base,
+        mergeDeep(input.model.options),
+        mergeDeep(input.agent.options),
+        mergeDeep(variant),
+      )
+      if (isOpenaiOauth) {
+        options.instructions = system.join("\n")
+      }
+
+      const isWorkflow = language instanceof GitLabWorkflowLanguageModel
+      const messages = isOpenaiOauth
+        ? input.messages
+        : isWorkflow
+          ? input.messages
+          : [
+              ...system.map(
+                (x): ModelMessage => ({
+                  role: "system",
+                  content: x,
+                }),
+              ),
+              ...input.messages,
+            ]
+
+      const params = yield* plugin.trigger(
+        "chat.params",
+        {
+          sessionID: input.sessionID,
+          agent: input.agent.name,
+          model: input.model,
+          provider: item,
+          message: input.user,
+        },
+        {
+          temperature: input.model.capabilities.temperature
+            ? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
+            : undefined,
+          topP: input.agent.topP ?? ProviderTransform.topP(input.model),
+          topK: ProviderTransform.topK(input.model),
+          maxOutputTokens: ProviderTransform.maxOutputTokens(input.model),
+          options,
+        },
+      )
+
+      const { headers } = yield* plugin.trigger(
+        "chat.headers",
+        {
+          sessionID: input.sessionID,
+          agent: input.agent.name,
+          model: input.model,
+          provider: item,
+          message: input.user,
+        },
+        {
+          headers: {},
+        },
+      )
+
+      const tools = resolveTools(input)
+
+      // LiteLLM and some Anthropic proxies require the tools parameter to be present
+      // when message history contains tool calls, even if no tools are being used.
+      // Add a dummy tool that is never called to satisfy this validation.
+      // This is enabled for:
+      // 1. Providers with "litellm" in their ID or API ID (auto-detected)
+      // 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
+      const isLiteLLMProxy =
+        item.options?.["litellmProxy"] === true ||
+        input.model.providerID.toLowerCase().includes("litellm") ||
+        input.model.api.id.toLowerCase().includes("litellm")
+
+      // LiteLLM/Bedrock rejects requests where the message history contains tool
+      // calls but no tools param is present. When there are no active tools (e.g.
+      // during compaction), inject a stub tool to satisfy the validation requirement.
+      // The stub description explicitly tells the model not to call it.
+      if (
+        (isLiteLLMProxy || input.model.providerID.includes("github-copilot")) &&
+        Object.keys(tools).length === 0 &&
+        hasToolCalls(input.messages)
+      ) {
+        tools["_noop"] = tool({
+          description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
+          inputSchema: jsonSchema({
+            type: "object",
+            properties: {
+              reason: { type: "string", description: "Unused" },
+            },
+          }),
+          execute: async () => ({ output: "", title: "", metadata: {} }),
+        })
+      }
 
-        // Wire up toolExecutor for DWS workflow models so that tool calls
-        // from the workflow service are executed via opencode's tool system
-        // and results sent back over the WebSocket.
-        if (language instanceof GitLabWorkflowLanguageModel) {
-          const workflowModel = language as GitLabWorkflowLanguageModel & {
-            sessionID?: string
-            sessionPreapprovedTools?: string[]
-            approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
+      // Wire up toolExecutor for DWS workflow models so that tool calls
+      // from the workflow service are executed via opencode's tool system
+      // and results sent back over the WebSocket.
+      if (language instanceof GitLabWorkflowLanguageModel) {
+        const workflowModel = language as GitLabWorkflowLanguageModel & {
+          sessionID?: string
+          sessionPreapprovedTools?: string[]
+          approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
+        }
+        workflowModel.sessionID = input.sessionID
+        workflowModel.systemPrompt = system.join("\n")
+        workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
+          const t = tools[toolName]
+          if (!t || !t.execute) {
+            return { result: "", error: `Unknown tool: ${toolName}` }
           }
-          workflowModel.sessionID = input.sessionID
-          workflowModel.systemPrompt = system.join("\n")
-          workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
-            const t = tools[toolName]
-            if (!t || !t.execute) {
-              return { result: "", error: `Unknown tool: ${toolName}` }
-            }
-            try {
-              const result = await t.execute!(JSON.parse(argsJson), {
-                toolCallId: _requestID,
-                messages: input.messages,
-                abortSignal: input.abort,
-              })
-              const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result))
-              return {
-                result: output,
-                metadata: typeof result === "object" ? result?.metadata : undefined,
-                title: typeof result === "object" ? result?.title : undefined,
-              }
-            } catch (e: any) {
-              return { result: "", error: e.message ?? String(e) }
+          try {
+            const result = await t.execute!(JSON.parse(argsJson), {
+              toolCallId: _requestID,
+              messages: input.messages,
+              abortSignal: input.abort,
+            })
+            const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result))
+            return {
+              result: output,
+              metadata: typeof result === "object" ? result?.metadata : undefined,
+              title: typeof result === "object" ? result?.title : undefined,
             }
+          } catch (e: any) {
+            return { result: "", error: e.message ?? String(e) }
           }
+        }
 
-          const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
-          workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => {
-            const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
-            return !match || match.action !== "ask"
-          })
+        const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
+        workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => {
+          const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
+          return !match || match.action !== "ask"
+        })
 
-          const bridge = yield* EffectBridge.make()
-          const approvedToolsForSession = new Set<string>()
-          workflowModel.approvalHandler = Instance.bind(async (approvalTools) => {
-            const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
-            // Auto-approve tools that were already approved in this session
-            // (prevents infinite approval loops for server-side MCP tools)
-            if (uniqueNames.every((name) => approvedToolsForSession.has(name))) {
-              return { approved: true }
-            }
+        const bridge = yield* EffectBridge.make()
+        const approvedToolsForSession = new Set<string>()
+        workflowModel.approvalHandler = Instance.bind(async (approvalTools) => {
+          const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
+          // Auto-approve tools that were already approved in this session
+          // (prevents infinite approval loops for server-side MCP tools)
+          if (uniqueNames.every((name) => approvedToolsForSession.has(name))) {
+            return { approved: true }
+          }
 
-            const id = PermissionID.ascending()
-            let reply: Permission.Reply | undefined
-            let unsub: (() => void) | undefined
-            try {
-              unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
-                if (evt.properties.requestID === id) reply = evt.properties.reply
-              })
-              const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
-                try {
-                  const parsed = JSON.parse(t.args) as Record<string, unknown>
-                  const title = (parsed?.title ?? parsed?.name ?? "") as string
-                  return title ? `${t.name}: ${title}` : t.name
-                } catch {
-                  return t.name
-                }
-              })
-              const uniquePatterns = [...new Set(toolPatterns)] as string[]
-              await bridge.promise(
-                perm.ask({
-                  id,
-                  sessionID: SessionID.make(input.sessionID),
-                  permission: "workflow_tool_approval",
-                  patterns: uniquePatterns,
-                  metadata: { tools: approvalTools },
-                  always: uniquePatterns,
-                  ruleset: [],
-                }),
-              )
-              for (const name of uniqueNames) approvedToolsForSession.add(name)
-              workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames]
-              return { approved: true }
-            } catch {
-              return { approved: false }
-            } finally {
-              unsub?.()
-            }
-          })
-        }
+          const id = PermissionID.ascending()
+          let reply: Permission.Reply | undefined
+          let unsub: (() => void) | undefined
+          try {
+            unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
+              if (evt.properties.requestID === id) reply = evt.properties.reply
+            })
+            const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
+              try {
+                const parsed = JSON.parse(t.args) as Record<string, unknown>
+                const title = (parsed?.title ?? parsed?.name ?? "") as string
+                return title ? `${t.name}: ${title}` : t.name
+              } catch {
+                return t.name
+              }
+            })
+            const uniquePatterns = [...new Set(toolPatterns)] as string[]
+            await bridge.promise(
+              perm.ask({
+                id,
+                sessionID: SessionID.make(input.sessionID),
+                permission: "workflow_tool_approval",
+                patterns: uniquePatterns,
+                metadata: { tools: approvalTools },
+                always: uniquePatterns,
+                ruleset: [],
+              }),
+            )
+            for (const name of uniqueNames) approvedToolsForSession.add(name)
+            workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames]
+            return { approved: true }
+          } catch {
+            return { approved: false }
+          } finally {
+            unsub?.()
+          }
+        })
+      }
 
-        const tracer = cfg.experimental?.openTelemetry
-          ? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
-          : undefined
+      const tracer = cfg.experimental?.openTelemetry
+        ? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
+        : undefined
 
-        return streamText({
-          onError(error) {
-            l.error("stream error", {
-              error,
+      return streamText({
+        onError(error) {
+          l.error("stream error", {
+            error,
+          })
+        },
+        async experimental_repairToolCall(failed) {
+          const lower = failed.toolCall.toolName.toLowerCase()
+          if (lower !== failed.toolCall.toolName && tools[lower]) {
+            l.info("repairing tool call", {
+              tool: failed.toolCall.toolName,
+              repaired: lower,
             })
-          },
-          async experimental_repairToolCall(failed) {
-            const lower = failed.toolCall.toolName.toLowerCase()
-            if (lower !== failed.toolCall.toolName && tools[lower]) {
-              l.info("repairing tool call", {
-                tool: failed.toolCall.toolName,
-                repaired: lower,
-              })
-              return {
-                ...failed.toolCall,
-                toolName: lower,
-              }
-            }
             return {
               ...failed.toolCall,
-              input: JSON.stringify({
-                tool: failed.toolCall.toolName,
-                error: failed.error.message,
-              }),
-              toolName: "invalid",
+              toolName: lower,
             }
-          },
-          temperature: params.temperature,
-          topP: params.topP,
-          topK: params.topK,
-          providerOptions: ProviderTransform.providerOptions(input.model, params.options),
-          activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
-          tools,
-          toolChoice: input.toolChoice,
-          maxOutputTokens: params.maxOutputTokens,
-          abortSignal: input.abort,
-          headers: {
-            ...(input.model.providerID.startsWith("opencode")
-              ? {
-                  "x-opencode-project": Instance.project.id,
-                  "x-opencode-session": input.sessionID,
-                  "x-opencode-request": input.user.id,
-                  "x-opencode-client": Flag.OPENCODE_CLIENT,
+          }
+          return {
+            ...failed.toolCall,
+            input: JSON.stringify({
+              tool: failed.toolCall.toolName,
+              error: failed.error.message,
+            }),
+            toolName: "invalid",
+          }
+        },
+        temperature: params.temperature,
+        topP: params.topP,
+        topK: params.topK,
+        providerOptions: ProviderTransform.providerOptions(input.model, params.options),
+        activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
+        tools,
+        toolChoice: input.toolChoice,
+        maxOutputTokens: params.maxOutputTokens,
+        abortSignal: input.abort,
+        headers: {
+          ...(input.model.providerID.startsWith("opencode")
+            ? {
+                "x-opencode-project": Instance.project.id,
+                "x-opencode-session": input.sessionID,
+                "x-opencode-request": input.user.id,
+                "x-opencode-client": Flag.OPENCODE_CLIENT,
+              }
+            : {
+                "x-session-affinity": input.sessionID,
+                ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
+                "User-Agent": `opencode/${Installation.VERSION}`,
+              }),
+          ...input.model.headers,
+          ...headers,
+        },
+        maxRetries: input.retries ?? 0,
+        messages,
+        model: wrapLanguageModel({
+          model: language,
+          middleware: [
+            {
+              specificationVersion: "v3" as const,
+              async transformParams(args) {
+                if (args.type === "stream") {
+                  // @ts-expect-error
+                  args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
                 }
-              : {
-                  "x-session-affinity": input.sessionID,
-                  ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
-                  "User-Agent": `opencode/${Installation.VERSION}`,
-                }),
-            ...input.model.headers,
-            ...headers,
-          },
-          maxRetries: input.retries ?? 0,
-          messages,
-          model: wrapLanguageModel({
-            model: language,
-            middleware: [
-              {
-                specificationVersion: "v3" as const,
-                async transformParams(args) {
-                  if (args.type === "stream") {
-                    // @ts-expect-error
-                    args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
-                  }
-                  return args.params
-                },
+                return args.params
               },
-            ],
-          }),
-          experimental_telemetry: {
-            isEnabled: cfg.experimental?.openTelemetry,
-            functionId: "session.llm",
-            tracer,
-            metadata: {
-              userId: cfg.username ?? "unknown",
-              sessionId: input.sessionID,
             },
+          ],
+        }),
+        experimental_telemetry: {
+          isEnabled: cfg.experimental?.openTelemetry,
+          functionId: "session.llm",
+          tracer,
+          metadata: {
+            userId: cfg.username ?? "unknown",
+            sessionId: input.sessionID,
           },
-        })
+        },
       })
+    })
 
-      const stream: Interface["stream"] = (input) =>
-        Stream.scoped(
-          Stream.unwrap(
-            Effect.gen(function* () {
-              const ctrl = yield* Effect.acquireRelease(
-                Effect.sync(() => new AbortController()),
-                (ctrl) => Effect.sync(() => ctrl.abort()),
-              )
+    const stream: Interface["stream"] = (input) =>
+      Stream.scoped(
+        Stream.unwrap(
+          Effect.gen(function* () {
+            const ctrl = yield* Effect.acquireRelease(
+              Effect.sync(() => new AbortController()),
+              (ctrl) => Effect.sync(() => ctrl.abort()),
+            )
 
-              const result = yield* run({ ...input, abort: ctrl.signal })
+            const result = yield* run({ ...input, abort: ctrl.signal })
 
-              return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e))))
-            }),
-          ),
-        )
-
-      return Service.of({ stream })
-    }),
-  )
-
-  export const layer = live.pipe(Layer.provide(Permission.defaultLayer))
-
-  export const defaultLayer = Layer.suspend(() =>
-    layer.pipe(
-      Layer.provide(Auth.defaultLayer),
-      Layer.provide(Config.defaultLayer),
-      Layer.provide(Provider.defaultLayer),
-      Layer.provide(Plugin.defaultLayer),
-    ),
+            return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e))))
+          }),
+        ),
+      )
+
+    return Service.of({ stream })
+  }),
+)
+
+export const layer = live.pipe(Layer.provide(Permission.defaultLayer))
+
+export const defaultLayer = Layer.suspend(() =>
+  layer.pipe(
+    Layer.provide(Auth.defaultLayer),
+    Layer.provide(Config.defaultLayer),
+    Layer.provide(Provider.defaultLayer),
+    Layer.provide(Plugin.defaultLayer),
+  ),
+)
+
+function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
+  const disabled = Permission.disabled(
+    Object.keys(input.tools),
+    Permission.merge(input.agent.permission, input.permission ?? []),
   )
+  return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
+}
 
-  function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
-    const disabled = Permission.disabled(
-      Object.keys(input.tools),
-      Permission.merge(input.agent.permission, input.permission ?? []),
-    )
-    return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
-  }
-
-  // Check if messages contain any tool-call content
-  // Used to determine if a dummy tool should be added for LiteLLM proxy compatibility
-  export function hasToolCalls(messages: ModelMessage[]): boolean {
-    for (const msg of messages) {
-      if (!Array.isArray(msg.content)) continue
-      for (const part of msg.content) {
-        if (part.type === "tool-call" || part.type === "tool-result") return true
-      }
+// Check if messages contain any tool-call content
+// Used to determine if a dummy tool should be added for LiteLLM proxy compatibility
+export function hasToolCalls(messages: ModelMessage[]): boolean {
+  for (const msg of messages) {
+    if (!Array.isArray(msg.content)) continue
+    for (const part of msg.content) {
+      if (part.type === "tool-call" || part.type === "tool-result") return true
     }
-    return false
   }
+  return false
 }

Dosya farkı çok büyük olduğundan ihmal edildi
+ 665 - 653
packages/opencode/src/session/message-v2.ts


+ 169 - 171
packages/opencode/src/session/message.ts

@@ -3,189 +3,187 @@ import { SessionID } from "./schema"
 import { ModelID, ProviderID } from "../provider/schema"
 import { NamedError } from "@opencode-ai/shared/util/error"
 
-export namespace Message {
-  export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
-  export const AuthError = NamedError.create(
-    "ProviderAuthError",
-    z.object({
-      providerID: z.string(),
-      message: z.string(),
-    }),
-  )
+export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
+export const AuthError = NamedError.create(
+  "ProviderAuthError",
+  z.object({
+    providerID: z.string(),
+    message: z.string(),
+  }),
+)
 
-  export const ToolCall = z
-    .object({
-      state: z.literal("call"),
-      step: z.number().optional(),
-      toolCallId: z.string(),
-      toolName: z.string(),
-      args: z.custom<Required<unknown>>(),
-    })
-    .meta({
-      ref: "ToolCall",
-    })
-  export type ToolCall = z.infer<typeof ToolCall>
-
-  export const ToolPartialCall = z
-    .object({
-      state: z.literal("partial-call"),
-      step: z.number().optional(),
-      toolCallId: z.string(),
-      toolName: z.string(),
-      args: z.custom<Required<unknown>>(),
-    })
-    .meta({
-      ref: "ToolPartialCall",
-    })
-  export type ToolPartialCall = z.infer<typeof ToolPartialCall>
+export const ToolCall = z
+  .object({
+    state: z.literal("call"),
+    step: z.number().optional(),
+    toolCallId: z.string(),
+    toolName: z.string(),
+    args: z.custom<Required<unknown>>(),
+  })
+  .meta({
+    ref: "ToolCall",
+  })
+export type ToolCall = z.infer<typeof ToolCall>
 
-  export const ToolResult = z
-    .object({
-      state: z.literal("result"),
-      step: z.number().optional(),
-      toolCallId: z.string(),
-      toolName: z.string(),
-      args: z.custom<Required<unknown>>(),
-      result: z.string(),
-    })
-    .meta({
-      ref: "ToolResult",
-    })
-  export type ToolResult = z.infer<typeof ToolResult>
+export const ToolPartialCall = z
+  .object({
+    state: z.literal("partial-call"),
+    step: z.number().optional(),
+    toolCallId: z.string(),
+    toolName: z.string(),
+    args: z.custom<Required<unknown>>(),
+  })
+  .meta({
+    ref: "ToolPartialCall",
+  })
+export type ToolPartialCall = z.infer<typeof ToolPartialCall>
 
-  export const ToolInvocation = z.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]).meta({
-    ref: "ToolInvocation",
+export const ToolResult = z
+  .object({
+    state: z.literal("result"),
+    step: z.number().optional(),
+    toolCallId: z.string(),
+    toolName: z.string(),
+    args: z.custom<Required<unknown>>(),
+    result: z.string(),
+  })
+  .meta({
+    ref: "ToolResult",
   })
-  export type ToolInvocation = z.infer<typeof ToolInvocation>
+export type ToolResult = z.infer<typeof ToolResult>
 
-  export const TextPart = z
-    .object({
-      type: z.literal("text"),
-      text: z.string(),
-    })
-    .meta({
-      ref: "TextPart",
-    })
-  export type TextPart = z.infer<typeof TextPart>
+export const ToolInvocation = z.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]).meta({
+  ref: "ToolInvocation",
+})
+export type ToolInvocation = z.infer<typeof ToolInvocation>
 
-  export const ReasoningPart = z
-    .object({
-      type: z.literal("reasoning"),
-      text: z.string(),
-      providerMetadata: z.record(z.string(), z.any()).optional(),
-    })
-    .meta({
-      ref: "ReasoningPart",
-    })
-  export type ReasoningPart = z.infer<typeof ReasoningPart>
+export const TextPart = z
+  .object({
+    type: z.literal("text"),
+    text: z.string(),
+  })
+  .meta({
+    ref: "TextPart",
+  })
+export type TextPart = z.infer<typeof TextPart>
+
+export const ReasoningPart = z
+  .object({
+    type: z.literal("reasoning"),
+    text: z.string(),
+    providerMetadata: z.record(z.string(), z.any()).optional(),
+  })
+  .meta({
+    ref: "ReasoningPart",
+  })
+export type ReasoningPart = z.infer<typeof ReasoningPart>
 
-  export const ToolInvocationPart = z
-    .object({
-      type: z.literal("tool-invocation"),
-      toolInvocation: ToolInvocation,
-    })
-    .meta({
-      ref: "ToolInvocationPart",
-    })
-  export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>
+export const ToolInvocationPart = z
+  .object({
+    type: z.literal("tool-invocation"),
+    toolInvocation: ToolInvocation,
+  })
+  .meta({
+    ref: "ToolInvocationPart",
+  })
+export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>
 
-  export const SourceUrlPart = z
-    .object({
-      type: z.literal("source-url"),
-      sourceId: z.string(),
-      url: z.string(),
-      title: z.string().optional(),
-      providerMetadata: z.record(z.string(), z.any()).optional(),
-    })
-    .meta({
-      ref: "SourceUrlPart",
-    })
-  export type SourceUrlPart = z.infer<typeof SourceUrlPart>
+export const SourceUrlPart = z
+  .object({
+    type: z.literal("source-url"),
+    sourceId: z.string(),
+    url: z.string(),
+    title: z.string().optional(),
+    providerMetadata: z.record(z.string(), z.any()).optional(),
+  })
+  .meta({
+    ref: "SourceUrlPart",
+  })
+export type SourceUrlPart = z.infer<typeof SourceUrlPart>
 
-  export const FilePart = z
-    .object({
-      type: z.literal("file"),
-      mediaType: z.string(),
-      filename: z.string().optional(),
-      url: z.string(),
-    })
-    .meta({
-      ref: "FilePart",
-    })
-  export type FilePart = z.infer<typeof FilePart>
+export const FilePart = z
+  .object({
+    type: z.literal("file"),
+    mediaType: z.string(),
+    filename: z.string().optional(),
+    url: z.string(),
+  })
+  .meta({
+    ref: "FilePart",
+  })
+export type FilePart = z.infer<typeof FilePart>
 
-  export const StepStartPart = z
-    .object({
-      type: z.literal("step-start"),
-    })
-    .meta({
-      ref: "StepStartPart",
-    })
-  export type StepStartPart = z.infer<typeof StepStartPart>
+export const StepStartPart = z
+  .object({
+    type: z.literal("step-start"),
+  })
+  .meta({
+    ref: "StepStartPart",
+  })
+export type StepStartPart = z.infer<typeof StepStartPart>
 
-  export const MessagePart = z
-    .discriminatedUnion("type", [TextPart, ReasoningPart, ToolInvocationPart, SourceUrlPart, FilePart, StepStartPart])
-    .meta({
-      ref: "MessagePart",
-    })
-  export type MessagePart = z.infer<typeof MessagePart>
+export const MessagePart = z
+  .discriminatedUnion("type", [TextPart, ReasoningPart, ToolInvocationPart, SourceUrlPart, FilePart, StepStartPart])
+  .meta({
+    ref: "MessagePart",
+  })
+export type MessagePart = z.infer<typeof MessagePart>
 
-  export const Info = z
-    .object({
-      id: z.string(),
-      role: z.enum(["user", "assistant"]),
-      parts: z.array(MessagePart),
-      metadata: z
-        .object({
-          time: z.object({
-            created: z.number(),
-            completed: z.number().optional(),
-          }),
-          error: z
-            .discriminatedUnion("name", [AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema])
-            .optional(),
-          sessionID: SessionID.zod,
-          tool: z.record(
-            z.string(),
-            z
-              .object({
-                title: z.string(),
-                snapshot: z.string().optional(),
-                time: z.object({
-                  start: z.number(),
-                  end: z.number(),
-                }),
-              })
-              .catchall(z.any()),
-          ),
-          assistant: z
+export const Info = z
+  .object({
+    id: z.string(),
+    role: z.enum(["user", "assistant"]),
+    parts: z.array(MessagePart),
+    metadata: z
+      .object({
+        time: z.object({
+          created: z.number(),
+          completed: z.number().optional(),
+        }),
+        error: z
+          .discriminatedUnion("name", [AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema])
+          .optional(),
+        sessionID: SessionID.zod,
+        tool: z.record(
+          z.string(),
+          z
             .object({
-              system: z.string().array(),
-              modelID: ModelID.zod,
-              providerID: ProviderID.zod,
-              path: z.object({
-                cwd: z.string(),
-                root: z.string(),
-              }),
-              cost: z.number(),
-              summary: z.boolean().optional(),
-              tokens: z.object({
-                input: z.number(),
-                output: z.number(),
-                reasoning: z.number(),
-                cache: z.object({
-                  read: z.number(),
-                  write: z.number(),
-                }),
+              title: z.string(),
+              snapshot: z.string().optional(),
+              time: z.object({
+                start: z.number(),
+                end: z.number(),
               }),
             })
-            .optional(),
-          snapshot: z.string().optional(),
-        })
-        .meta({ ref: "MessageMetadata" }),
-    })
-    .meta({
-      ref: "Message",
-    })
-  export type Info = z.infer<typeof Info>
-}
+            .catchall(z.any()),
+        ),
+        assistant: z
+          .object({
+            system: z.string().array(),
+            modelID: ModelID.zod,
+            providerID: ProviderID.zod,
+            path: z.object({
+              cwd: z.string(),
+              root: z.string(),
+            }),
+            cost: z.number(),
+            summary: z.boolean().optional(),
+            tokens: z.object({
+              input: z.number(),
+              output: z.number(),
+              reasoning: z.number(),
+              cache: z.object({
+                read: z.number(),
+                write: z.number(),
+              }),
+            }),
+          })
+          .optional(),
+        snapshot: z.string().optional(),
+      })
+      .meta({ ref: "MessageMetadata" }),
+  })
+  .meta({
+    ref: "Message",
+  })
+export type Info = z.infer<typeof Info>

+ 1 - 1
packages/opencode/src/session/overflow.ts

@@ -1,7 +1,7 @@
 import type { Config } from "@/config"
 import type { Provider } from "@/provider"
 import { ProviderTransform } from "@/provider/transform"
-import type { MessageV2 } from "./message-v2"
+import type { MessageV2 } from "."
 
 const COMPACTION_BUFFER = 20_000
 

+ 546 - 548
packages/opencode/src/session/processor.ts

@@ -7,613 +7,611 @@ import { Permission } from "@/permission"
 import { Plugin } from "@/plugin"
 import { Snapshot } from "@/snapshot"
 import { Session } from "."
-import { LLM } from "./llm"
-import { MessageV2 } from "./message-v2"
+import { LLM } from "."
+import { MessageV2 } from "."
 import { isOverflow } from "./overflow"
 import { PartID } from "./schema"
 import type { SessionID } from "./schema"
-import { SessionRetry } from "./retry"
-import { SessionStatus } from "./status"
-import { SessionSummary } from "./summary"
+import { SessionRetry } from "."
+import { SessionStatus } from "."
+import { SessionSummary } from "."
 import type { Provider } from "@/provider"
 import { Question } from "@/question"
 import { errorMessage } from "@/util/error"
 import { Log } from "@/util/log"
 import { isRecord } from "@/util/record"
 
-export namespace SessionProcessor {
-  const DOOM_LOOP_THRESHOLD = 3
-  const log = Log.create({ service: "session.processor" })
-
-  export type Result = "compact" | "stop" | "continue"
-
-  export type Event = LLM.Event
-
-  export interface Handle {
-    readonly message: MessageV2.Assistant
-    readonly updateToolCall: (
-      toolCallID: string,
-      update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
-    ) => Effect.Effect<MessageV2.ToolPart | undefined>
-    readonly completeToolCall: (
-      toolCallID: string,
-      output: {
-        title: string
-        metadata: Record<string, any>
-        output: string
-        attachments?: MessageV2.FilePart[]
-      },
-    ) => Effect.Effect<void>
-    readonly process: (streamInput: LLM.StreamInput) => Effect.Effect<Result>
-  }
-
-  type Input = {
-    assistantMessage: MessageV2.Assistant
-    sessionID: SessionID
-    model: Provider.Model
-  }
-
-  export interface Interface {
-    readonly create: (input: Input) => Effect.Effect<Handle>
-  }
-
-  type ToolCall = {
-    partID: MessageV2.ToolPart["id"]
-    messageID: MessageV2.ToolPart["messageID"]
-    sessionID: MessageV2.ToolPart["sessionID"]
-    done: Deferred.Deferred<void>
-  }
-
-  interface ProcessorContext extends Input {
-    toolcalls: Record<string, ToolCall>
-    shouldBreak: boolean
-    snapshot: string | undefined
-    blocked: boolean
-    needsCompaction: boolean
-    currentText: MessageV2.TextPart | undefined
-    reasoningMap: Record<string, MessageV2.ReasoningPart>
-  }
-
-  type StreamEvent = Event
-
-  export class Service extends Context.Service<Service, Interface>()("@opencode/SessionProcessor") {}
-
-  export const layer: Layer.Layer<
-    Service,
-    never,
-    | Session.Service
-    | Config.Service
-    | Bus.Service
-    | Snapshot.Service
-    | Agent.Service
-    | LLM.Service
-    | Permission.Service
-    | Plugin.Service
-    | SessionSummary.Service
-    | SessionStatus.Service
-  > = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const session = yield* Session.Service
-      const config = yield* Config.Service
-      const bus = yield* Bus.Service
-      const snapshot = yield* Snapshot.Service
-      const agents = yield* Agent.Service
-      const llm = yield* LLM.Service
-      const permission = yield* Permission.Service
-      const plugin = yield* Plugin.Service
-      const summary = yield* SessionSummary.Service
-      const scope = yield* Scope.Scope
-      const status = yield* SessionStatus.Service
-
-      const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
-        // Pre-capture snapshot before the LLM stream starts. The AI SDK
-        // may execute tools internally before emitting start-step events,
-        // so capturing inside the event handler can be too late.
-        const initialSnapshot = yield* snapshot.track()
-        const ctx: ProcessorContext = {
-          assistantMessage: input.assistantMessage,
-          sessionID: input.sessionID,
-          model: input.model,
-          toolcalls: {},
-          shouldBreak: false,
-          snapshot: initialSnapshot,
-          blocked: false,
-          needsCompaction: false,
-          currentText: undefined,
-          reasoningMap: {},
-        }
-        let aborted = false
-        const slog = log.clone().tag("sessionID", input.sessionID).tag("messageID", input.assistantMessage.id)
+const DOOM_LOOP_THRESHOLD = 3
+const log = Log.create({ service: "session.processor" })
+
+export type Result = "compact" | "stop" | "continue"
+
+export type Event = LLM.Event
+
+export interface Handle {
+  readonly message: MessageV2.Assistant
+  readonly updateToolCall: (
+    toolCallID: string,
+    update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
+  ) => Effect.Effect<MessageV2.ToolPart | undefined>
+  readonly completeToolCall: (
+    toolCallID: string,
+    output: {
+      title: string
+      metadata: Record<string, any>
+      output: string
+      attachments?: MessageV2.FilePart[]
+    },
+  ) => Effect.Effect<void>
+  readonly process: (streamInput: LLM.StreamInput) => Effect.Effect<Result>
+}
 
-        const parse = (e: unknown) =>
-          MessageV2.fromError(e, {
-            providerID: input.model.providerID,
-            aborted,
-          })
+type Input = {
+  assistantMessage: MessageV2.Assistant
+  sessionID: SessionID
+  model: Provider.Model
+}
 
-        const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) {
-          const done = ctx.toolcalls[toolCallID]?.done
-          delete ctx.toolcalls[toolCallID]
-          if (done) yield* Deferred.succeed(done, undefined).pipe(Effect.ignore)
-        })
+export interface Interface {
+  readonly create: (input: Input) => Effect.Effect<Handle>
+}
 
-        const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) {
-          const call = ctx.toolcalls[toolCallID]
-          if (!call) return
-          const part = yield* session.getPart({
-            partID: call.partID,
-            messageID: call.messageID,
-            sessionID: call.sessionID,
-          })
-          if (!part || part.type !== "tool") {
-            delete ctx.toolcalls[toolCallID]
-            return
-          }
-          return { call, part }
+type ToolCall = {
+  partID: MessageV2.ToolPart["id"]
+  messageID: MessageV2.ToolPart["messageID"]
+  sessionID: MessageV2.ToolPart["sessionID"]
+  done: Deferred.Deferred<void>
+}
+
+interface ProcessorContext extends Input {
+  toolcalls: Record<string, ToolCall>
+  shouldBreak: boolean
+  snapshot: string | undefined
+  blocked: boolean
+  needsCompaction: boolean
+  currentText: MessageV2.TextPart | undefined
+  reasoningMap: Record<string, MessageV2.ReasoningPart>
+}
+
+type StreamEvent = Event
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/SessionProcessor") {}
+
+export const layer: Layer.Layer<
+  Service,
+  never,
+  | Session.Service
+  | Config.Service
+  | Bus.Service
+  | Snapshot.Service
+  | Agent.Service
+  | LLM.Service
+  | Permission.Service
+  | Plugin.Service
+  | SessionSummary.Service
+  | SessionStatus.Service
+> = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const session = yield* Session.Service
+    const config = yield* Config.Service
+    const bus = yield* Bus.Service
+    const snapshot = yield* Snapshot.Service
+    const agents = yield* Agent.Service
+    const llm = yield* LLM.Service
+    const permission = yield* Permission.Service
+    const plugin = yield* Plugin.Service
+    const summary = yield* SessionSummary.Service
+    const scope = yield* Scope.Scope
+    const status = yield* SessionStatus.Service
+
+    const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
+      // Pre-capture snapshot before the LLM stream starts. The AI SDK
+      // may execute tools internally before emitting start-step events,
+      // so capturing inside the event handler can be too late.
+      const initialSnapshot = yield* snapshot.track()
+      const ctx: ProcessorContext = {
+        assistantMessage: input.assistantMessage,
+        sessionID: input.sessionID,
+        model: input.model,
+        toolcalls: {},
+        shouldBreak: false,
+        snapshot: initialSnapshot,
+        blocked: false,
+        needsCompaction: false,
+        currentText: undefined,
+        reasoningMap: {},
+      }
+      let aborted = false
+      const slog = log.clone().tag("sessionID", input.sessionID).tag("messageID", input.assistantMessage.id)
+
+      const parse = (e: unknown) =>
+        MessageV2.fromError(e, {
+          providerID: input.model.providerID,
+          aborted,
         })
 
-        const updateToolCall = Effect.fn("SessionProcessor.updateToolCall")(function* (
-          toolCallID: string,
-          update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
-        ) {
-          const match = yield* readToolCall(toolCallID)
-          if (!match) return
-          const part = yield* session.updatePart(update(match.part))
-          ctx.toolcalls[toolCallID] = {
-            ...match.call,
-            partID: part.id,
-            messageID: part.messageID,
-            sessionID: part.sessionID,
-          }
-          return part
+      const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) {
+        const done = ctx.toolcalls[toolCallID]?.done
+        delete ctx.toolcalls[toolCallID]
+        if (done) yield* Deferred.succeed(done, undefined).pipe(Effect.ignore)
+      })
+
+      const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) {
+        const call = ctx.toolcalls[toolCallID]
+        if (!call) return
+        const part = yield* session.getPart({
+          partID: call.partID,
+          messageID: call.messageID,
+          sessionID: call.sessionID,
         })
+        if (!part || part.type !== "tool") {
+          delete ctx.toolcalls[toolCallID]
+          return
+        }
+        return { call, part }
+      })
+
+      const updateToolCall = Effect.fn("SessionProcessor.updateToolCall")(function* (
+        toolCallID: string,
+        update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
+      ) {
+        const match = yield* readToolCall(toolCallID)
+        if (!match) return
+        const part = yield* session.updatePart(update(match.part))
+        ctx.toolcalls[toolCallID] = {
+          ...match.call,
+          partID: part.id,
+          messageID: part.messageID,
+          sessionID: part.sessionID,
+        }
+        return part
+      })
 
-        const completeToolCall = Effect.fn("SessionProcessor.completeToolCall")(function* (
-          toolCallID: string,
-          output: {
-            title: string
-            metadata: Record<string, any>
-            output: string
-            attachments?: MessageV2.FilePart[]
+      const completeToolCall = Effect.fn("SessionProcessor.completeToolCall")(function* (
+        toolCallID: string,
+        output: {
+          title: string
+          metadata: Record<string, any>
+          output: string
+          attachments?: MessageV2.FilePart[]
+        },
+      ) {
+        const match = yield* readToolCall(toolCallID)
+        if (!match || match.part.state.status !== "running") return
+        yield* session.updatePart({
+          ...match.part,
+          state: {
+            status: "completed",
+            input: match.part.state.input,
+            output: output.output,
+            metadata: output.metadata,
+            title: output.title,
+            time: { start: match.part.state.time.start, end: Date.now() },
+            attachments: output.attachments,
           },
-        ) {
-          const match = yield* readToolCall(toolCallID)
-          if (!match || match.part.state.status !== "running") return
-          yield* session.updatePart({
-            ...match.part,
-            state: {
-              status: "completed",
-              input: match.part.state.input,
-              output: output.output,
-              metadata: output.metadata,
-              title: output.title,
-              time: { start: match.part.state.time.start, end: Date.now() },
-              attachments: output.attachments,
-            },
-          })
-          yield* settleToolCall(toolCallID)
         })
+        yield* settleToolCall(toolCallID)
+      })
 
-        const failToolCall = Effect.fn("SessionProcessor.failToolCall")(function* (toolCallID: string, error: unknown) {
-          const match = yield* readToolCall(toolCallID)
-          if (!match || match.part.state.status !== "running") return false
-          yield* session.updatePart({
-            ...match.part,
-            state: {
-              status: "error",
-              input: match.part.state.input,
-              error: errorMessage(error),
-              time: { start: match.part.state.time.start, end: Date.now() },
-            },
-          })
-          if (error instanceof Permission.RejectedError || error instanceof Question.RejectedError) {
-            ctx.blocked = ctx.shouldBreak
-          }
-          yield* settleToolCall(toolCallID)
-          return true
+      const failToolCall = Effect.fn("SessionProcessor.failToolCall")(function* (toolCallID: string, error: unknown) {
+        const match = yield* readToolCall(toolCallID)
+        if (!match || match.part.state.status !== "running") return false
+        yield* session.updatePart({
+          ...match.part,
+          state: {
+            status: "error",
+            input: match.part.state.input,
+            error: errorMessage(error),
+            time: { start: match.part.state.time.start, end: Date.now() },
+          },
         })
+        if (error instanceof Permission.RejectedError || error instanceof Question.RejectedError) {
+          ctx.blocked = ctx.shouldBreak
+        }
+        yield* settleToolCall(toolCallID)
+        return true
+      })
 
-        const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) {
-          switch (value.type) {
-            case "start":
-              yield* status.set(ctx.sessionID, { type: "busy" })
-              return
-
-            case "reasoning-start":
-              if (value.id in ctx.reasoningMap) return
-              ctx.reasoningMap[value.id] = {
-                id: PartID.ascending(),
-                messageID: ctx.assistantMessage.id,
-                sessionID: ctx.assistantMessage.sessionID,
-                type: "reasoning",
-                text: "",
-                time: { start: Date.now() },
-                metadata: value.providerMetadata,
-              }
-              yield* session.updatePart(ctx.reasoningMap[value.id])
-              return
+      const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) {
+        switch (value.type) {
+          case "start":
+            yield* status.set(ctx.sessionID, { type: "busy" })
+            return
 
-            case "reasoning-delta":
-              if (!(value.id in ctx.reasoningMap)) return
-              ctx.reasoningMap[value.id].text += value.text
-              if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
-              yield* session.updatePartDelta({
-                sessionID: ctx.reasoningMap[value.id].sessionID,
-                messageID: ctx.reasoningMap[value.id].messageID,
-                partID: ctx.reasoningMap[value.id].id,
-                field: "text",
-                delta: value.text,
-              })
-              return
+          case "reasoning-start":
+            if (value.id in ctx.reasoningMap) return
+            ctx.reasoningMap[value.id] = {
+              id: PartID.ascending(),
+              messageID: ctx.assistantMessage.id,
+              sessionID: ctx.assistantMessage.sessionID,
+              type: "reasoning",
+              text: "",
+              time: { start: Date.now() },
+              metadata: value.providerMetadata,
+            }
+            yield* session.updatePart(ctx.reasoningMap[value.id])
+            return
 
-            case "reasoning-end":
-              if (!(value.id in ctx.reasoningMap)) return
-              // oxlint-disable-next-line no-self-assign -- reactivity trigger
-              ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text
-              ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
-              if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
-              yield* session.updatePart(ctx.reasoningMap[value.id])
-              delete ctx.reasoningMap[value.id]
-              return
+          case "reasoning-delta":
+            if (!(value.id in ctx.reasoningMap)) return
+            ctx.reasoningMap[value.id].text += value.text
+            if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
+            yield* session.updatePartDelta({
+              sessionID: ctx.reasoningMap[value.id].sessionID,
+              messageID: ctx.reasoningMap[value.id].messageID,
+              partID: ctx.reasoningMap[value.id].id,
+              field: "text",
+              delta: value.text,
+            })
+            return
 
-            case "tool-input-start":
-              if (ctx.assistantMessage.summary) {
-                throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
-              }
-              const part = yield* session.updatePart({
-                id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(),
-                messageID: ctx.assistantMessage.id,
-                sessionID: ctx.assistantMessage.sessionID,
-                type: "tool",
-                tool: value.toolName,
-                callID: value.id,
-                state: { status: "pending", input: {}, raw: "" },
-                metadata: value.providerExecuted ? { providerExecuted: true } : undefined,
-              } satisfies MessageV2.ToolPart)
-              ctx.toolcalls[value.id] = {
-                done: yield* Deferred.make<void>(),
-                partID: part.id,
-                messageID: part.messageID,
-                sessionID: part.sessionID,
-              }
-              return
+          case "reasoning-end":
+            if (!(value.id in ctx.reasoningMap)) return
+            // oxlint-disable-next-line no-self-assign -- reactivity trigger
+            ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text
+            ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
+            if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
+            yield* session.updatePart(ctx.reasoningMap[value.id])
+            delete ctx.reasoningMap[value.id]
+            return
 
-            case "tool-input-delta":
-              return
+          case "tool-input-start":
+            if (ctx.assistantMessage.summary) {
+              throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
+            }
+            const part = yield* session.updatePart({
+              id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(),
+              messageID: ctx.assistantMessage.id,
+              sessionID: ctx.assistantMessage.sessionID,
+              type: "tool",
+              tool: value.toolName,
+              callID: value.id,
+              state: { status: "pending", input: {}, raw: "" },
+              metadata: value.providerExecuted ? { providerExecuted: true } : undefined,
+            } satisfies MessageV2.ToolPart)
+            ctx.toolcalls[value.id] = {
+              done: yield* Deferred.make<void>(),
+              partID: part.id,
+              messageID: part.messageID,
+              sessionID: part.sessionID,
+            }
+            return
 
-            case "tool-input-end":
-              return
+          case "tool-input-delta":
+            return
 
-            case "tool-call": {
-              if (ctx.assistantMessage.summary) {
-                throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
-              }
-              yield* updateToolCall(value.toolCallId, (match) => ({
-                ...match,
-                tool: value.toolName,
-                state: {
-                  ...match.state,
-                  status: "running",
-                  input: value.input,
-                  time: { start: Date.now() },
-                },
-                metadata: match.metadata?.providerExecuted
-                  ? { ...value.providerMetadata, providerExecuted: true }
-                  : value.providerMetadata,
-              }))
-
-              const parts = MessageV2.parts(ctx.assistantMessage.id)
-              const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
-
-              if (
-                recentParts.length !== DOOM_LOOP_THRESHOLD ||
-                !recentParts.every(
-                  (part) =>
-                    part.type === "tool" &&
-                    part.tool === value.toolName &&
-                    part.state.status !== "pending" &&
-                    JSON.stringify(part.state.input) === JSON.stringify(value.input),
-                )
-              ) {
-                return
-              }
+          case "tool-input-end":
+            return
 
-              const agent = yield* agents.get(ctx.assistantMessage.agent)
-              yield* permission.ask({
-                permission: "doom_loop",
-                patterns: [value.toolName],
-                sessionID: ctx.assistantMessage.sessionID,
-                metadata: { tool: value.toolName, input: value.input },
-                always: [value.toolName],
-                ruleset: agent.permission,
-              })
-              return
+          case "tool-call": {
+            if (ctx.assistantMessage.summary) {
+              throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
             }
-
-            case "tool-result": {
-              yield* completeToolCall(value.toolCallId, value.output)
+            yield* updateToolCall(value.toolCallId, (match) => ({
+              ...match,
+              tool: value.toolName,
+              state: {
+                ...match.state,
+                status: "running",
+                input: value.input,
+                time: { start: Date.now() },
+              },
+              metadata: match.metadata?.providerExecuted
+                ? { ...value.providerMetadata, providerExecuted: true }
+                : value.providerMetadata,
+            }))
+
+            const parts = MessageV2.parts(ctx.assistantMessage.id)
+            const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
+
+            if (
+              recentParts.length !== DOOM_LOOP_THRESHOLD ||
+              !recentParts.every(
+                (part) =>
+                  part.type === "tool" &&
+                  part.tool === value.toolName &&
+                  part.state.status !== "pending" &&
+                  JSON.stringify(part.state.input) === JSON.stringify(value.input),
+              )
+            ) {
               return
             }
 
-            case "tool-error": {
-              yield* failToolCall(value.toolCallId, value.error)
-              return
-            }
+            const agent = yield* agents.get(ctx.assistantMessage.agent)
+            yield* permission.ask({
+              permission: "doom_loop",
+              patterns: [value.toolName],
+              sessionID: ctx.assistantMessage.sessionID,
+              metadata: { tool: value.toolName, input: value.input },
+              always: [value.toolName],
+              ruleset: agent.permission,
+            })
+            return
+          }
 
-            case "error":
-              throw value.error
+          case "tool-result": {
+            yield* completeToolCall(value.toolCallId, value.output)
+            return
+          }
 
-            case "start-step":
-              if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
-              yield* session.updatePart({
-                id: PartID.ascending(),
-                messageID: ctx.assistantMessage.id,
-                sessionID: ctx.sessionID,
-                snapshot: ctx.snapshot,
-                type: "step-start",
-              })
-              return
+          case "tool-error": {
+            yield* failToolCall(value.toolCallId, value.error)
+            return
+          }
 
-            case "finish-step": {
-              const usage = Session.getUsage({
-                model: ctx.model,
-                usage: value.usage,
-                metadata: value.providerMetadata,
-              })
-              ctx.assistantMessage.finish = value.finishReason
-              ctx.assistantMessage.cost += usage.cost
-              ctx.assistantMessage.tokens = usage.tokens
-              yield* session.updatePart({
-                id: PartID.ascending(),
-                reason: value.finishReason,
-                snapshot: yield* snapshot.track(),
-                messageID: ctx.assistantMessage.id,
-                sessionID: ctx.assistantMessage.sessionID,
-                type: "step-finish",
-                tokens: usage.tokens,
-                cost: usage.cost,
-              })
-              yield* session.updateMessage(ctx.assistantMessage)
-              if (ctx.snapshot) {
-                const patch = yield* snapshot.patch(ctx.snapshot)
-                if (patch.files.length) {
-                  yield* session.updatePart({
-                    id: PartID.ascending(),
-                    messageID: ctx.assistantMessage.id,
-                    sessionID: ctx.sessionID,
-                    type: "patch",
-                    hash: patch.hash,
-                    files: patch.files,
-                  })
-                }
-                ctx.snapshot = undefined
-              }
-              yield* summary
-                .summarize({
+          case "error":
+            throw value.error
+
+          case "start-step":
+            if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
+            yield* session.updatePart({
+              id: PartID.ascending(),
+              messageID: ctx.assistantMessage.id,
+              sessionID: ctx.sessionID,
+              snapshot: ctx.snapshot,
+              type: "step-start",
+            })
+            return
+
+          case "finish-step": {
+            const usage = Session.getUsage({
+              model: ctx.model,
+              usage: value.usage,
+              metadata: value.providerMetadata,
+            })
+            ctx.assistantMessage.finish = value.finishReason
+            ctx.assistantMessage.cost += usage.cost
+            ctx.assistantMessage.tokens = usage.tokens
+            yield* session.updatePart({
+              id: PartID.ascending(),
+              reason: value.finishReason,
+              snapshot: yield* snapshot.track(),
+              messageID: ctx.assistantMessage.id,
+              sessionID: ctx.assistantMessage.sessionID,
+              type: "step-finish",
+              tokens: usage.tokens,
+              cost: usage.cost,
+            })
+            yield* session.updateMessage(ctx.assistantMessage)
+            if (ctx.snapshot) {
+              const patch = yield* snapshot.patch(ctx.snapshot)
+              if (patch.files.length) {
+                yield* session.updatePart({
+                  id: PartID.ascending(),
+                  messageID: ctx.assistantMessage.id,
                   sessionID: ctx.sessionID,
-                  messageID: ctx.assistantMessage.parentID,
+                  type: "patch",
+                  hash: patch.hash,
+                  files: patch.files,
                 })
-                .pipe(Effect.ignore, Effect.forkIn(scope))
-              if (
-                !ctx.assistantMessage.summary &&
-                isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })
-              ) {
-                ctx.needsCompaction = true
               }
-              return
+              ctx.snapshot = undefined
             }
-
-            case "text-start":
-              ctx.currentText = {
-                id: PartID.ascending(),
-                messageID: ctx.assistantMessage.id,
-                sessionID: ctx.assistantMessage.sessionID,
-                type: "text",
-                text: "",
-                time: { start: Date.now() },
-                metadata: value.providerMetadata,
-              }
-              yield* session.updatePart(ctx.currentText)
-              return
-
-            case "text-delta":
-              if (!ctx.currentText) return
-              ctx.currentText.text += value.text
-              if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
-              yield* session.updatePartDelta({
-                sessionID: ctx.currentText.sessionID,
-                messageID: ctx.currentText.messageID,
-                partID: ctx.currentText.id,
-                field: "text",
-                delta: value.text,
+            yield* summary
+              .summarize({
+                sessionID: ctx.sessionID,
+                messageID: ctx.assistantMessage.parentID,
               })
-              return
-
-            case "text-end":
-              if (!ctx.currentText) return
-              // oxlint-disable-next-line no-self-assign -- reactivity trigger
-              ctx.currentText.text = ctx.currentText.text
-              ctx.currentText.text = (yield* plugin.trigger(
-                "experimental.text.complete",
-                {
-                  sessionID: ctx.sessionID,
-                  messageID: ctx.assistantMessage.id,
-                  partID: ctx.currentText.id,
-                },
-                { text: ctx.currentText.text },
-              )).text
-              {
-                const end = Date.now()
-                ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
-              }
-              if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
-              yield* session.updatePart(ctx.currentText)
-              ctx.currentText = undefined
-              return
+              .pipe(Effect.ignore, Effect.forkIn(scope))
+            if (
+              !ctx.assistantMessage.summary &&
+              isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })
+            ) {
+              ctx.needsCompaction = true
+            }
+            return
+          }
 
-            case "finish":
-              return
+          case "text-start":
+            ctx.currentText = {
+              id: PartID.ascending(),
+              messageID: ctx.assistantMessage.id,
+              sessionID: ctx.assistantMessage.sessionID,
+              type: "text",
+              text: "",
+              time: { start: Date.now() },
+              metadata: value.providerMetadata,
+            }
+            yield* session.updatePart(ctx.currentText)
+            return
 
-            default:
-              slog.info("unhandled", { event: value.type, value })
-              return
-          }
-        })
+          case "text-delta":
+            if (!ctx.currentText) return
+            ctx.currentText.text += value.text
+            if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
+            yield* session.updatePartDelta({
+              sessionID: ctx.currentText.sessionID,
+              messageID: ctx.currentText.messageID,
+              partID: ctx.currentText.id,
+              field: "text",
+              delta: value.text,
+            })
+            return
 
-        const cleanup = Effect.fn("SessionProcessor.cleanup")(function* () {
-          if (ctx.snapshot) {
-            const patch = yield* snapshot.patch(ctx.snapshot)
-            if (patch.files.length) {
-              yield* session.updatePart({
-                id: PartID.ascending(),
-                messageID: ctx.assistantMessage.id,
+          case "text-end":
+            if (!ctx.currentText) return
+            // oxlint-disable-next-line no-self-assign -- reactivity trigger
+            ctx.currentText.text = ctx.currentText.text
+            ctx.currentText.text = (yield* plugin.trigger(
+              "experimental.text.complete",
+              {
                 sessionID: ctx.sessionID,
-                type: "patch",
-                hash: patch.hash,
-                files: patch.files,
-              })
+                messageID: ctx.assistantMessage.id,
+                partID: ctx.currentText.id,
+              },
+              { text: ctx.currentText.text },
+            )).text
+            {
+              const end = Date.now()
+              ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
             }
-            ctx.snapshot = undefined
-          }
-
-          if (ctx.currentText) {
-            const end = Date.now()
-            ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
+            if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
             yield* session.updatePart(ctx.currentText)
             ctx.currentText = undefined
-          }
+            return
 
-          for (const part of Object.values(ctx.reasoningMap)) {
-            const end = Date.now()
-            yield* session.updatePart({
-              ...part,
-              time: { start: part.time.start ?? end, end },
-            })
-          }
-          ctx.reasoningMap = {}
+          case "finish":
+            return
 
-          yield* Effect.forEach(
-            Object.values(ctx.toolcalls),
-            (call) => Deferred.await(call.done).pipe(Effect.timeout("250 millis"), Effect.ignore),
-            { concurrency: "unbounded" },
-          )
+          default:
+            slog.info("unhandled", { event: value.type, value })
+            return
+        }
+      })
 
-          for (const toolCallID of Object.keys(ctx.toolcalls)) {
-            const match = yield* readToolCall(toolCallID)
-            if (!match) continue
-            const part = match.part
-            const end = Date.now()
-            const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {}
+      const cleanup = Effect.fn("SessionProcessor.cleanup")(function* () {
+        if (ctx.snapshot) {
+          const patch = yield* snapshot.patch(ctx.snapshot)
+          if (patch.files.length) {
             yield* session.updatePart({
-              ...part,
-              state: {
-                ...part.state,
-                status: "error",
-                error: "Tool execution aborted",
-                metadata: { ...metadata, interrupted: true },
-                time: { start: "time" in part.state ? part.state.time.start : end, end },
-              },
+              id: PartID.ascending(),
+              messageID: ctx.assistantMessage.id,
+              sessionID: ctx.sessionID,
+              type: "patch",
+              hash: patch.hash,
+              files: patch.files,
             })
           }
-          ctx.toolcalls = {}
-          ctx.assistantMessage.time.completed = Date.now()
-          yield* session.updateMessage(ctx.assistantMessage)
-        })
+          ctx.snapshot = undefined
+        }
 
-        const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
-          slog.error("process", { error: errorMessage(e), stack: e instanceof Error ? e.stack : undefined })
-          const error = parse(e)
-          if (MessageV2.ContextOverflowError.isInstance(error)) {
-            ctx.needsCompaction = true
-            yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
-            return
-          }
-          ctx.assistantMessage.error = error
-          yield* bus.publish(Session.Event.Error, {
-            sessionID: ctx.assistantMessage.sessionID,
-            error: ctx.assistantMessage.error,
+        if (ctx.currentText) {
+          const end = Date.now()
+          ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
+          yield* session.updatePart(ctx.currentText)
+          ctx.currentText = undefined
+        }
+
+        for (const part of Object.values(ctx.reasoningMap)) {
+          const end = Date.now()
+          yield* session.updatePart({
+            ...part,
+            time: { start: part.time.start ?? end, end },
           })
-          yield* status.set(ctx.sessionID, { type: "idle" })
-        })
+        }
+        ctx.reasoningMap = {}
 
-        const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
-          slog.info("process")
-          ctx.needsCompaction = false
-          ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
-
-          return yield* Effect.gen(function* () {
-            yield* Effect.gen(function* () {
-              ctx.currentText = undefined
-              ctx.reasoningMap = {}
-              const stream = llm.stream(streamInput)
-
-              yield* stream.pipe(
-                Stream.tap((event) => handleEvent(event)),
-                Stream.takeUntil(() => ctx.needsCompaction),
-                Stream.runDrain,
-              )
-            }).pipe(
-              Effect.onInterrupt(() =>
-                Effect.gen(function* () {
-                  aborted = true
-                  if (!ctx.assistantMessage.error) {
-                    yield* halt(new DOMException("Aborted", "AbortError"))
-                  }
-                }),
-              ),
-              Effect.catchCauseIf(
-                (cause) => !Cause.hasInterruptsOnly(cause),
-                (cause) => Effect.fail(Cause.squash(cause)),
-              ),
-              Effect.retry(
-                SessionRetry.policy({
-                  parse,
-                  set: (info) =>
-                    status.set(ctx.sessionID, {
-                      type: "retry",
-                      attempt: info.attempt,
-                      message: info.message,
-                      next: info.next,
-                    }),
-                }),
-              ),
-              Effect.catch(halt),
-              Effect.ensuring(cleanup()),
-            )
+        yield* Effect.forEach(
+          Object.values(ctx.toolcalls),
+          (call) => Deferred.await(call.done).pipe(Effect.timeout("250 millis"), Effect.ignore),
+          { concurrency: "unbounded" },
+        )
 
-            if (ctx.needsCompaction) return "compact"
-            if (ctx.blocked || ctx.assistantMessage.error) return "stop"
-            return "continue"
+        for (const toolCallID of Object.keys(ctx.toolcalls)) {
+          const match = yield* readToolCall(toolCallID)
+          if (!match) continue
+          const part = match.part
+          const end = Date.now()
+          const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {}
+          yield* session.updatePart({
+            ...part,
+            state: {
+              ...part.state,
+              status: "error",
+              error: "Tool execution aborted",
+              metadata: { ...metadata, interrupted: true },
+              time: { start: "time" in part.state ? part.state.time.start : end, end },
+            },
           })
+        }
+        ctx.toolcalls = {}
+        ctx.assistantMessage.time.completed = Date.now()
+        yield* session.updateMessage(ctx.assistantMessage)
+      })
+
+      const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
+        slog.error("process", { error: errorMessage(e), stack: e instanceof Error ? e.stack : undefined })
+        const error = parse(e)
+        if (MessageV2.ContextOverflowError.isInstance(error)) {
+          ctx.needsCompaction = true
+          yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
+          return
+        }
+        ctx.assistantMessage.error = error
+        yield* bus.publish(Session.Event.Error, {
+          sessionID: ctx.assistantMessage.sessionID,
+          error: ctx.assistantMessage.error,
         })
+        yield* status.set(ctx.sessionID, { type: "idle" })
+      })
 
-        return {
-          get message() {
-            return ctx.assistantMessage
-          },
-          updateToolCall,
-          completeToolCall,
-          process,
-        } satisfies Handle
+      const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
+        slog.info("process")
+        ctx.needsCompaction = false
+        ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
+
+        return yield* Effect.gen(function* () {
+          yield* Effect.gen(function* () {
+            ctx.currentText = undefined
+            ctx.reasoningMap = {}
+            const stream = llm.stream(streamInput)
+
+            yield* stream.pipe(
+              Stream.tap((event) => handleEvent(event)),
+              Stream.takeUntil(() => ctx.needsCompaction),
+              Stream.runDrain,
+            )
+          }).pipe(
+            Effect.onInterrupt(() =>
+              Effect.gen(function* () {
+                aborted = true
+                if (!ctx.assistantMessage.error) {
+                  yield* halt(new DOMException("Aborted", "AbortError"))
+                }
+              }),
+            ),
+            Effect.catchCauseIf(
+              (cause) => !Cause.hasInterruptsOnly(cause),
+              (cause) => Effect.fail(Cause.squash(cause)),
+            ),
+            Effect.retry(
+              SessionRetry.policy({
+                parse,
+                set: (info) =>
+                  status.set(ctx.sessionID, {
+                    type: "retry",
+                    attempt: info.attempt,
+                    message: info.message,
+                    next: info.next,
+                  }),
+              }),
+            ),
+            Effect.catch(halt),
+            Effect.ensuring(cleanup()),
+          )
+
+          if (ctx.needsCompaction) return "compact"
+          if (ctx.blocked || ctx.assistantMessage.error) return "stop"
+          return "continue"
+        })
       })
 
-      return Service.of({ create })
-    }),
-  )
-
-  export const defaultLayer = Layer.suspend(() =>
-    layer.pipe(
-      Layer.provide(Session.defaultLayer),
-      Layer.provide(Snapshot.defaultLayer),
-      Layer.provide(Agent.defaultLayer),
-      Layer.provide(LLM.defaultLayer),
-      Layer.provide(Permission.defaultLayer),
-      Layer.provide(Plugin.defaultLayer),
-      Layer.provide(SessionSummary.defaultLayer),
-      Layer.provide(SessionStatus.defaultLayer),
-      Layer.provide(Bus.layer),
-      Layer.provide(Config.defaultLayer),
-    ),
-  )
-}
+      return {
+        get message() {
+          return ctx.assistantMessage
+        },
+        updateToolCall,
+        completeToolCall,
+        process,
+      } satisfies Handle
+    })
+
+    return Service.of({ create })
+  }),
+)
+
+export const defaultLayer = Layer.suspend(() =>
+  layer.pipe(
+    Layer.provide(Session.defaultLayer),
+    Layer.provide(Snapshot.defaultLayer),
+    Layer.provide(Agent.defaultLayer),
+    Layer.provide(LLM.defaultLayer),
+    Layer.provide(Permission.defaultLayer),
+    Layer.provide(Plugin.defaultLayer),
+    Layer.provide(SessionSummary.defaultLayer),
+    Layer.provide(SessionStatus.defaultLayer),
+    Layer.provide(Bus.layer),
+    Layer.provide(Config.defaultLayer),
+  ),
+)

+ 1 - 1
packages/opencode/src/session/projectors.ts

@@ -1,7 +1,7 @@
 import { NotFoundError, eq, and } from "../storage/db"
 import { SyncEvent } from "@/sync"
 import { Session } from "."
-import { MessageV2 } from "./message-v2"
+import { MessageV2 } from "."
 import { SessionTable, MessageTable, PartTable } from "./session.sql"
 import { Log } from "../util/log"
 

+ 1568 - 1570
packages/opencode/src/session/prompt.ts

@@ -2,19 +2,19 @@ import path from "path"
 import os from "os"
 import z from "zod"
 import { SessionID, MessageID, PartID } from "./schema"
-import { MessageV2 } from "./message-v2"
+import { MessageV2 } from "."
 import { Log } from "../util/log"
-import { SessionRevert } from "./revert"
+import { SessionRevert } from "."
 import { Session } from "."
 import { Agent } from "../agent/agent"
 import { Provider } from "../provider"
 import { ModelID, ProviderID } from "../provider/schema"
 import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
-import { SessionCompaction } from "./compaction"
+import { SessionCompaction } from "."
 import { Bus } from "../bus"
 import { ProviderTransform } from "../provider/transform"
-import { SystemPrompt } from "./system"
-import { Instruction } from "./instruction"
+import { SystemPrompt } from "."
+import { Instruction } from "."
 import { Plugin } from "../plugin"
 import PROMPT_PLAN from "../session/prompt/plan.txt"
 import BUILD_SWITCH from "../session/prompt/build-switch.txt"
@@ -31,13 +31,13 @@ import * as Stream from "effect/Stream"
 import { Command } from "../command"
 import { pathToFileURL, fileURLToPath } from "url"
 import { ConfigMarkdown } from "../config/markdown"
-import { SessionSummary } from "./summary"
+import { SessionSummary } from "."
 import { NamedError } from "@opencode-ai/shared/util/error"
-import { SessionProcessor } from "./processor"
+import { SessionProcessor } from "."
 import { Tool } from "@/tool/tool"
 import { Permission } from "@/permission"
-import { SessionStatus } from "./status"
-import { LLM } from "./llm"
+import { SessionStatus } from "."
+import { LLM } from "."
 import { Shell } from "@/shell/shell"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { Truncate } from "@/tool/truncate"
@@ -47,7 +47,7 @@ import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect"
 import { EffectLogger } from "@/effect/logger"
 import { InstanceState } from "@/effect"
 import { TaskTool, type TaskPromptOps } from "@/tool/task"
-import { SessionRunState } from "./run-state"
+import { SessionRunState } from "."
 import { EffectBridge } from "@/effect"
 
 // @ts-ignore
@@ -63,221 +63,220 @@ 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.`
 
-export namespace SessionPrompt {
-  const log = Log.create({ service: "session.prompt" })
-  const elog = EffectLogger.create({ service: "session.prompt" })
-
-  export interface Interface {
-    readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
-    readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
-    readonly loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts>
-    readonly shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts>
-    readonly command: (input: CommandInput) => Effect.Effect<MessageV2.WithParts>
-    readonly resolvePromptParts: (template: string) => Effect.Effect<PromptInput["parts"]>
-  }
-
-  export class Service extends Context.Service<Service, Interface>()("@opencode/SessionPrompt") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const bus = yield* Bus.Service
-      const status = yield* SessionStatus.Service
-      const sessions = yield* Session.Service
-      const agents = yield* Agent.Service
-      const provider = yield* Provider.Service
-      const processor = yield* SessionProcessor.Service
-      const compaction = yield* SessionCompaction.Service
-      const plugin = yield* Plugin.Service
-      const commands = yield* Command.Service
-      const permission = yield* Permission.Service
-      const fsys = yield* AppFileSystem.Service
-      const mcp = yield* MCP.Service
-      const lsp = yield* LSP.Service
-      const filetime = yield* FileTime.Service
-      const registry = yield* ToolRegistry.Service
-      const truncate = yield* Truncate.Service
-      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
-      const scope = yield* Scope.Scope
-      const instruction = yield* Instruction.Service
-      const state = yield* SessionRunState.Service
-      const revert = yield* SessionRevert.Service
-      const summary = yield* SessionSummary.Service
-      const sys = yield* SystemPrompt.Service
-      const llm = yield* LLM.Service
-      const runner = Effect.fn("SessionPrompt.runner")(function* () {
-        return yield* EffectBridge.make()
-      })
-      const ops = Effect.fn("SessionPrompt.ops")(function* () {
-        const run = yield* runner()
-        return {
-          cancel: (sessionID: SessionID) => run.fork(cancel(sessionID)),
-          resolvePromptParts: (template: string) => resolvePromptParts(template),
-          prompt: (input: PromptInput) => prompt(input),
-        } satisfies TaskPromptOps
-      })
+const log = Log.create({ service: "session.prompt" })
+const elog = EffectLogger.create({ service: "session.prompt" })
 
-      const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
-        yield* elog.info("cancel", { sessionID })
-        yield* state.cancel(sessionID)
-      })
+export interface Interface {
+  readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
+  readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
+  readonly loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts>
+  readonly shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts>
+  readonly command: (input: CommandInput) => Effect.Effect<MessageV2.WithParts>
+  readonly resolvePromptParts: (template: string) => Effect.Effect<PromptInput["parts"]>
+}
 
-      const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
-        const ctx = yield* InstanceState.context
-        const parts: PromptInput["parts"] = [{ type: "text", text: template }]
-        const files = ConfigMarkdown.files(template)
-        const seen = new Set<string>()
-        yield* Effect.forEach(
-          files,
-          Effect.fnUntraced(function* (match) {
-            const name = match[1]
-            if (seen.has(name)) return
-            seen.add(name)
-            const filepath = name.startsWith("~/")
-              ? path.join(os.homedir(), name.slice(2))
-              : path.resolve(ctx.worktree, name)
-
-            const info = yield* fsys.stat(filepath).pipe(Effect.option)
-            if (Option.isNone(info)) {
-              const found = yield* agents.get(name)
-              if (found) parts.push({ type: "agent", name: found.name })
-              return
-            }
-            const stat = info.value
-            parts.push({
-              type: "file",
-              url: pathToFileURL(filepath).href,
-              filename: name,
-              mime: stat.type === "Directory" ? "application/x-directory" : "text/plain",
-            })
-          }),
-          { concurrency: "unbounded", discard: true },
-        )
-        return parts
-      })
+export class Service extends Context.Service<Service, Interface>()("@opencode/SessionPrompt") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const bus = yield* Bus.Service
+    const status = yield* SessionStatus.Service
+    const sessions = yield* Session.Service
+    const agents = yield* Agent.Service
+    const provider = yield* Provider.Service
+    const processor = yield* SessionProcessor.Service
+    const compaction = yield* SessionCompaction.Service
+    const plugin = yield* Plugin.Service
+    const commands = yield* Command.Service
+    const permission = yield* Permission.Service
+    const fsys = yield* AppFileSystem.Service
+    const mcp = yield* MCP.Service
+    const lsp = yield* LSP.Service
+    const filetime = yield* FileTime.Service
+    const registry = yield* ToolRegistry.Service
+    const truncate = yield* Truncate.Service
+    const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+    const scope = yield* Scope.Scope
+    const instruction = yield* Instruction.Service
+    const state = yield* SessionRunState.Service
+    const revert = yield* SessionRevert.Service
+    const summary = yield* SessionSummary.Service
+    const sys = yield* SystemPrompt.Service
+    const llm = yield* LLM.Service
+    const runner = Effect.fn("SessionPrompt.runner")(function* () {
+      return yield* EffectBridge.make()
+    })
+    const ops = Effect.fn("SessionPrompt.ops")(function* () {
+      const run = yield* runner()
+      return {
+        cancel: (sessionID: SessionID) => run.fork(cancel(sessionID)),
+        resolvePromptParts: (template: string) => resolvePromptParts(template),
+        prompt: (input: PromptInput) => prompt(input),
+      } satisfies TaskPromptOps
+    })
 
-      const title = Effect.fn("SessionPrompt.ensureTitle")(function* (input: {
-        session: Session.Info
-        history: MessageV2.WithParts[]
-        providerID: ProviderID
-        modelID: ModelID
-      }) {
-        if (input.session.parentID) return
-        if (!Session.isDefaultTitle(input.session.title)) return
-
-        const real = (m: MessageV2.WithParts) =>
-          m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic)
-        const idx = input.history.findIndex(real)
-        if (idx === -1) return
-        if (input.history.filter(real).length !== 1) return
-
-        const context = input.history.slice(0, idx + 1)
-        const firstUser = context[idx]
-        if (!firstUser || firstUser.info.role !== "user") return
-        const firstInfo = firstUser.info
-
-        const subtasks = firstUser.parts.filter((p): p is MessageV2.SubtaskPart => p.type === "subtask")
-        const onlySubtasks = subtasks.length > 0 && firstUser.parts.every((p) => p.type === "subtask")
-
-        const ag = yield* agents.get("title")
-        if (!ag) return
-        const mdl = ag.model
-          ? yield* provider.getModel(ag.model.providerID, ag.model.modelID)
-          : ((yield* provider.getSmallModel(input.providerID)) ??
-            (yield* provider.getModel(input.providerID, input.modelID)))
-        const msgs = onlySubtasks
-          ? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
-          : yield* MessageV2.toModelMessagesEffect(context, mdl)
-        const text = yield* llm
-          .stream({
-            agent: ag,
-            user: firstInfo,
-            system: [],
-            small: true,
-            tools: {},
-            model: mdl,
-            sessionID: input.session.id,
-            retries: 2,
-            messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
-          })
-          .pipe(
-            Stream.filter((e): e is Extract<LLM.Event, { type: "text-delta" }> => e.type === "text-delta"),
-            Stream.map((e) => e.text),
-            Stream.mkString,
-            Effect.orDie,
-          )
-        const cleaned = text
-          .replace(/<think>[\s\S]*?<\/think>\s*/g, "")
-          .split("\n")
-          .map((line) => line.trim())
-          .find((line) => line.length > 0)
-        if (!cleaned) return
-        const t = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
-        yield* sessions
-          .setTitle({ sessionID: input.session.id, title: t })
-          .pipe(Effect.catchCause((cause) => elog.error("failed to generate title", { error: Cause.squash(cause) })))
-      })
+    const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
+      yield* elog.info("cancel", { sessionID })
+      yield* state.cancel(sessionID)
+    })
 
-      const insertReminders = Effect.fn("SessionPrompt.insertReminders")(function* (input: {
-        messages: MessageV2.WithParts[]
-        agent: Agent.Info
-        session: Session.Info
-      }) {
-        const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
-        if (!userMessage) return input.messages
-
-        if (!Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE) {
-          if (input.agent.name === "plan") {
-            userMessage.parts.push({
-              id: PartID.ascending(),
-              messageID: userMessage.info.id,
-              sessionID: userMessage.info.sessionID,
-              type: "text",
-              text: PROMPT_PLAN,
-              synthetic: true,
-            })
-          }
-          const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
-          if (wasPlan && input.agent.name === "build") {
-            userMessage.parts.push({
-              id: PartID.ascending(),
-              messageID: userMessage.info.id,
-              sessionID: userMessage.info.sessionID,
-              type: "text",
-              text: BUILD_SWITCH,
-              synthetic: true,
-            })
+    const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
+      const ctx = yield* InstanceState.context
+      const parts: PromptInput["parts"] = [{ type: "text", text: template }]
+      const files = ConfigMarkdown.files(template)
+      const seen = new Set<string>()
+      yield* Effect.forEach(
+        files,
+        Effect.fnUntraced(function* (match) {
+          const name = match[1]
+          if (seen.has(name)) return
+          seen.add(name)
+          const filepath = name.startsWith("~/")
+            ? path.join(os.homedir(), name.slice(2))
+            : path.resolve(ctx.worktree, name)
+
+          const info = yield* fsys.stat(filepath).pipe(Effect.option)
+          if (Option.isNone(info)) {
+            const found = yield* agents.get(name)
+            if (found) parts.push({ type: "agent", name: found.name })
+            return
           }
-          return input.messages
-        }
+          const stat = info.value
+          parts.push({
+            type: "file",
+            url: pathToFileURL(filepath).href,
+            filename: name,
+            mime: stat.type === "Directory" ? "application/x-directory" : "text/plain",
+          })
+        }),
+        { concurrency: "unbounded", discard: true },
+      )
+      return parts
+    })
+
+    const title = Effect.fn("SessionPrompt.ensureTitle")(function* (input: {
+      session: Session.Info
+      history: MessageV2.WithParts[]
+      providerID: ProviderID
+      modelID: ModelID
+    }) {
+      if (input.session.parentID) return
+      if (!Session.isDefaultTitle(input.session.title)) return
+
+      const real = (m: MessageV2.WithParts) =>
+        m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic)
+      const idx = input.history.findIndex(real)
+      if (idx === -1) return
+      if (input.history.filter(real).length !== 1) return
+
+      const context = input.history.slice(0, idx + 1)
+      const firstUser = context[idx]
+      if (!firstUser || firstUser.info.role !== "user") return
+      const firstInfo = firstUser.info
+
+      const subtasks = firstUser.parts.filter((p): p is MessageV2.SubtaskPart => p.type === "subtask")
+      const onlySubtasks = subtasks.length > 0 && firstUser.parts.every((p) => p.type === "subtask")
+
+      const ag = yield* agents.get("title")
+      if (!ag) return
+      const mdl = ag.model
+        ? yield* provider.getModel(ag.model.providerID, ag.model.modelID)
+        : ((yield* provider.getSmallModel(input.providerID)) ??
+          (yield* provider.getModel(input.providerID, input.modelID)))
+      const msgs = onlySubtasks
+        ? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
+        : yield* MessageV2.toModelMessagesEffect(context, mdl)
+      const text = yield* llm
+        .stream({
+          agent: ag,
+          user: firstInfo,
+          system: [],
+          small: true,
+          tools: {},
+          model: mdl,
+          sessionID: input.session.id,
+          retries: 2,
+          messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
+        })
+        .pipe(
+          Stream.filter((e): e is Extract<LLM.Event, { type: "text-delta" }> => e.type === "text-delta"),
+          Stream.map((e) => e.text),
+          Stream.mkString,
+          Effect.orDie,
+        )
+      const cleaned = text
+        .replace(/<think>[\s\S]*?<\/think>\s*/g, "")
+        .split("\n")
+        .map((line) => line.trim())
+        .find((line) => line.length > 0)
+      if (!cleaned) return
+      const t = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
+      yield* sessions
+        .setTitle({ sessionID: input.session.id, title: t })
+        .pipe(Effect.catchCause((cause) => elog.error("failed to generate title", { error: Cause.squash(cause) })))
+    })
 
-        const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant")
-        if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
-          const plan = Session.plan(input.session)
-          if (!(yield* fsys.existsSafe(plan))) return input.messages
-          const part = yield* sessions.updatePart({
+    const insertReminders = Effect.fn("SessionPrompt.insertReminders")(function* (input: {
+      messages: MessageV2.WithParts[]
+      agent: Agent.Info
+      session: Session.Info
+    }) {
+      const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
+      if (!userMessage) return input.messages
+
+      if (!Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE) {
+        if (input.agent.name === "plan") {
+          userMessage.parts.push({
             id: PartID.ascending(),
             messageID: userMessage.info.id,
             sessionID: userMessage.info.sessionID,
             type: "text",
-            text: `${BUILD_SWITCH}\n\nA plan file exists at ${plan}. You should execute on the plan defined within it`,
+            text: PROMPT_PLAN,
             synthetic: true,
           })
-          userMessage.parts.push(part)
-          return input.messages
         }
+        const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
+        if (wasPlan && input.agent.name === "build") {
+          userMessage.parts.push({
+            id: PartID.ascending(),
+            messageID: userMessage.info.id,
+            sessionID: userMessage.info.sessionID,
+            type: "text",
+            text: BUILD_SWITCH,
+            synthetic: true,
+          })
+        }
+        return input.messages
+      }
 
-        if (input.agent.name !== "plan" || assistantMessage?.info.agent === "plan") return input.messages
-
+      const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant")
+      if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
         const plan = Session.plan(input.session)
-        const exists = yield* fsys.existsSafe(plan)
-        if (!exists) yield* fsys.ensureDir(path.dirname(plan)).pipe(Effect.catch(Effect.die))
+        if (!(yield* fsys.existsSafe(plan))) return input.messages
         const part = yield* sessions.updatePart({
           id: PartID.ascending(),
           messageID: userMessage.info.id,
           sessionID: userMessage.info.sessionID,
           type: "text",
-          text: `<system-reminder>
+          text: `${BUILD_SWITCH}\n\nA plan file exists at ${plan}. You should execute on the plan defined within it`,
+          synthetic: true,
+        })
+        userMessage.parts.push(part)
+        return input.messages
+      }
+
+      if (input.agent.name !== "plan" || assistantMessage?.info.agent === "plan") return input.messages
+
+      const plan = Session.plan(input.session)
+      const exists = yield* fsys.existsSafe(plan)
+      if (!exists) yield* fsys.ensureDir(path.dirname(plan)).pipe(Effect.catch(Effect.die))
+      const part = yield* sessions.updatePart({
+        id: PartID.ascending(),
+        messageID: userMessage.info.id,
+        sessionID: userMessage.info.sessionID,
+        type: "text",
+        text: `<system-reminder>
 Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received.
 
 ## Plan File Info:
@@ -292,10 +291,10 @@ Goal: Gain a comprehensive understanding of the user's request by reading throug
 1. Focus on understanding the user's request and the code associated with their request
 
 2. **Launch up to 3 explore agents IN PARALLEL** (single message, multiple tool calls) to efficiently explore the codebase.
-   - Use 1 agent when the task is isolated to known files, the user provided specific file paths, or you're making a small targeted change.
-   - Use multiple agents when: the scope is uncertain, multiple areas of the codebase are involved, or you need to understand existing patterns before planning.
-   - Quality over quantity - 3 agents maximum, but you should try to use the minimum number of agents necessary (usually just 1)
-   - If using multiple agents: Provide each agent with a specific search focus or area to explore. Example: One agent searches for existing implementations, another explores related components, a third investigates testing patterns
+ - Use 1 agent when the task is isolated to known files, the user provided specific file paths, or you're making a small targeted change.
+ - Use multiple agents when: the scope is uncertain, multiple areas of the codebase are involved, or you need to understand existing patterns before planning.
+ - Quality over quantity - 3 agents maximum, but you should try to use the minimum number of agents necessary (usually just 1)
+ - If using multiple agents: Provide each agent with a specific search focus or area to explore. Example: One agent searches for existing implementations, another explores related components, a third investigates testing patterns
 
 3. After exploring the code, use the question tool to clarify ambiguities in the user request up front.
 
@@ -347,1509 +346,1508 @@ This is critical - your turn should only end with either asking the user a quest
 
 NOTE: At any point in time through this workflow you should feel free to ask the user questions or clarifications. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins.
 </system-reminder>`,
-          synthetic: true,
-        })
-        userMessage.parts.push(part)
-        return input.messages
+        synthetic: true,
       })
+      userMessage.parts.push(part)
+      return input.messages
+    })
 
-      const resolveTools = Effect.fn("SessionPrompt.resolveTools")(function* (input: {
-        agent: Agent.Info
-        model: Provider.Model
-        session: Session.Info
-        tools?: Record<string, boolean>
-        processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
-        bypassAgentCheck: boolean
-        messages: MessageV2.WithParts[]
-      }) {
-        using _ = log.time("resolveTools")
-        const tools: Record<string, AITool> = {}
-        const run = yield* runner()
-        const promptOps = yield* ops()
-
-        const context = (args: any, options: ToolExecutionOptions): Tool.Context => ({
-          sessionID: input.session.id,
-          abort: options.abortSignal!,
-          messageID: input.processor.message.id,
-          callID: options.toolCallId,
-          extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps },
-          agent: input.agent.name,
-          messages: input.messages,
-          metadata: (val) =>
-            input.processor.updateToolCall(options.toolCallId, (match) => {
-              if (!["running", "pending"].includes(match.state.status)) return match
-              return {
-                ...match,
-                state: {
-                  title: val.title,
-                  metadata: val.metadata,
-                  status: "running",
-                  input: args,
-                  time: { start: Date.now() },
-                },
-              }
-            }),
-          ask: (req) =>
-            permission
-              .ask({
-                ...req,
-                sessionID: input.session.id,
-                tool: { messageID: input.processor.message.id, callID: options.toolCallId },
-                ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
-              })
-              .pipe(Effect.orDie),
-        })
-
-        for (const item of yield* registry.tools({
-          modelID: ModelID.make(input.model.api.id),
-          providerID: input.model.providerID,
-          agent: input.agent,
-        })) {
-          const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
-          tools[item.id] = tool({
-            id: item.id as any,
-            description: item.description,
-            inputSchema: jsonSchema(schema as any),
-            execute(args, options) {
-              return run.promise(
-                Effect.gen(function* () {
-                  const ctx = context(args, options)
-                  yield* plugin.trigger(
-                    "tool.execute.before",
-                    { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
-                    { args },
-                  )
-                  const result = yield* item.execute(args, ctx)
-                  const output = {
-                    ...result,
-                    attachments: result.attachments?.map((attachment) => ({
-                      ...attachment,
-                      id: PartID.ascending(),
-                      sessionID: ctx.sessionID,
-                      messageID: input.processor.message.id,
-                    })),
-                  }
-                  yield* plugin.trigger(
-                    "tool.execute.after",
-                    { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
-                    output,
-                  )
-                  if (options.abortSignal?.aborted) {
-                    yield* input.processor.completeToolCall(options.toolCallId, output)
-                  }
-                  return output
-                }),
-              )
-            },
-          })
-        }
-
-        for (const [key, item] of Object.entries(yield* mcp.tools())) {
-          const execute = item.execute
-          if (!execute) continue
+    const resolveTools = Effect.fn("SessionPrompt.resolveTools")(function* (input: {
+      agent: Agent.Info
+      model: Provider.Model
+      session: Session.Info
+      tools?: Record<string, boolean>
+      processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
+      bypassAgentCheck: boolean
+      messages: MessageV2.WithParts[]
+    }) {
+      using _ = log.time("resolveTools")
+      const tools: Record<string, AITool> = {}
+      const run = yield* runner()
+      const promptOps = yield* ops()
+
+      const context = (args: any, options: ToolExecutionOptions): Tool.Context => ({
+        sessionID: input.session.id,
+        abort: options.abortSignal!,
+        messageID: input.processor.message.id,
+        callID: options.toolCallId,
+        extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps },
+        agent: input.agent.name,
+        messages: input.messages,
+        metadata: (val) =>
+          input.processor.updateToolCall(options.toolCallId, (match) => {
+            if (!["running", "pending"].includes(match.state.status)) return match
+            return {
+              ...match,
+              state: {
+                title: val.title,
+                metadata: val.metadata,
+                status: "running",
+                input: args,
+                time: { start: Date.now() },
+              },
+            }
+          }),
+        ask: (req) =>
+          permission
+            .ask({
+              ...req,
+              sessionID: input.session.id,
+              tool: { messageID: input.processor.message.id, callID: options.toolCallId },
+              ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
+            })
+            .pipe(Effect.orDie),
+      })
 
-          const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
-          const transformed = ProviderTransform.schema(input.model, schema)
-          item.inputSchema = jsonSchema(transformed)
-          item.execute = (args, opts) =>
-            run.promise(
+      for (const item of yield* registry.tools({
+        modelID: ModelID.make(input.model.api.id),
+        providerID: input.model.providerID,
+        agent: input.agent,
+      })) {
+        const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
+        tools[item.id] = tool({
+          id: item.id as any,
+          description: item.description,
+          inputSchema: jsonSchema(schema as any),
+          execute(args, options) {
+            return run.promise(
               Effect.gen(function* () {
-                const ctx = context(args, opts)
+                const ctx = context(args, options)
                 yield* plugin.trigger(
                   "tool.execute.before",
-                  { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
+                  { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
                   { args },
                 )
-                yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
-                const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.promise(() =>
-                  execute(args, opts),
-                )
-                yield* plugin.trigger(
-                  "tool.execute.after",
-                  { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
-                  result,
-                )
-
-                const textParts: string[] = []
-                const attachments: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[] = []
-                for (const contentItem of result.content) {
-                  if (contentItem.type === "text") textParts.push(contentItem.text)
-                  else if (contentItem.type === "image") {
-                    attachments.push({
-                      type: "file",
-                      mime: contentItem.mimeType,
-                      url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
-                    })
-                  } else if (contentItem.type === "resource") {
-                    const { resource } = contentItem
-                    if (resource.text) textParts.push(resource.text)
-                    if (resource.blob) {
-                      attachments.push({
-                        type: "file",
-                        mime: resource.mimeType ?? "application/octet-stream",
-                        url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
-                        filename: resource.uri,
-                      })
-                    }
-                  }
-                }
-
-                const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent)
-                const metadata = {
-                  ...result.metadata,
-                  truncated: truncated.truncated,
-                  ...(truncated.truncated && { outputPath: truncated.outputPath }),
-                }
-
+                const result = yield* item.execute(args, ctx)
                 const output = {
-                  title: "",
-                  metadata,
-                  output: truncated.content,
-                  attachments: attachments.map((attachment) => ({
+                  ...result,
+                  attachments: result.attachments?.map((attachment) => ({
                     ...attachment,
                     id: PartID.ascending(),
                     sessionID: ctx.sessionID,
                     messageID: input.processor.message.id,
                   })),
-                  content: result.content,
                 }
-                if (opts.abortSignal?.aborted) {
-                  yield* input.processor.completeToolCall(opts.toolCallId, output)
+                yield* plugin.trigger(
+                  "tool.execute.after",
+                  { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
+                  output,
+                )
+                if (options.abortSignal?.aborted) {
+                  yield* input.processor.completeToolCall(options.toolCallId, output)
                 }
                 return output
               }),
             )
-          tools[key] = item
-        }
+          },
+        })
+      }
+
+      for (const [key, item] of Object.entries(yield* mcp.tools())) {
+        const execute = item.execute
+        if (!execute) continue
+
+        const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
+        const transformed = ProviderTransform.schema(input.model, schema)
+        item.inputSchema = jsonSchema(transformed)
+        item.execute = (args, opts) =>
+          run.promise(
+            Effect.gen(function* () {
+              const ctx = context(args, opts)
+              yield* plugin.trigger(
+                "tool.execute.before",
+                { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
+                { args },
+              )
+              yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
+              const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.promise(() =>
+                execute(args, opts),
+              )
+              yield* plugin.trigger(
+                "tool.execute.after",
+                { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
+                result,
+              )
+
+              const textParts: string[] = []
+              const attachments: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[] = []
+              for (const contentItem of result.content) {
+                if (contentItem.type === "text") textParts.push(contentItem.text)
+                else if (contentItem.type === "image") {
+                  attachments.push({
+                    type: "file",
+                    mime: contentItem.mimeType,
+                    url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
+                  })
+                } else if (contentItem.type === "resource") {
+                  const { resource } = contentItem
+                  if (resource.text) textParts.push(resource.text)
+                  if (resource.blob) {
+                    attachments.push({
+                      type: "file",
+                      mime: resource.mimeType ?? "application/octet-stream",
+                      url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
+                      filename: resource.uri,
+                    })
+                  }
+                }
+              }
+
+              const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent)
+              const metadata = {
+                ...result.metadata,
+                truncated: truncated.truncated,
+                ...(truncated.truncated && { outputPath: truncated.outputPath }),
+              }
+
+              const output = {
+                title: "",
+                metadata,
+                output: truncated.content,
+                attachments: attachments.map((attachment) => ({
+                  ...attachment,
+                  id: PartID.ascending(),
+                  sessionID: ctx.sessionID,
+                  messageID: input.processor.message.id,
+                })),
+                content: result.content,
+              }
+              if (opts.abortSignal?.aborted) {
+                yield* input.processor.completeToolCall(opts.toolCallId, output)
+              }
+              return output
+            }),
+          )
+        tools[key] = item
+      }
+
+      return tools
+    })
 
-        return tools
+    const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input: {
+      task: MessageV2.SubtaskPart
+      model: Provider.Model
+      lastUser: MessageV2.User
+      sessionID: SessionID
+      session: Session.Info
+      msgs: MessageV2.WithParts[]
+    }) {
+      const { task, model, lastUser, sessionID, session, msgs } = input
+      const ctx = yield* InstanceState.context
+      const promptOps = yield* ops()
+      const { task: taskTool } = yield* registry.named()
+      const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
+      const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
+        id: MessageID.ascending(),
+        role: "assistant",
+        parentID: lastUser.id,
+        sessionID,
+        mode: task.agent,
+        agent: task.agent,
+        variant: lastUser.model.variant,
+        path: { cwd: ctx.directory, root: ctx.worktree },
+        cost: 0,
+        tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+        modelID: taskModel.id,
+        providerID: taskModel.providerID,
+        time: { created: Date.now() },
       })
+      let part: MessageV2.ToolPart = yield* sessions.updatePart({
+        id: PartID.ascending(),
+        messageID: assistantMessage.id,
+        sessionID: assistantMessage.sessionID,
+        type: "tool",
+        callID: ulid(),
+        tool: TaskTool.id,
+        state: {
+          status: "running",
+          input: {
+            prompt: task.prompt,
+            description: task.description,
+            subagent_type: task.agent,
+            command: task.command,
+          },
+          time: { start: Date.now() },
+        },
+      })
+      const taskArgs = {
+        prompt: task.prompt,
+        description: task.description,
+        subagent_type: task.agent,
+        command: task.command,
+      }
+      yield* plugin.trigger(
+        "tool.execute.before",
+        { tool: TaskTool.id, sessionID, callID: part.id },
+        { args: taskArgs },
+      )
 
-      const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input: {
-        task: MessageV2.SubtaskPart
-        model: Provider.Model
-        lastUser: MessageV2.User
-        sessionID: SessionID
-        session: Session.Info
-        msgs: MessageV2.WithParts[]
-      }) {
-        const { task, model, lastUser, sessionID, session, msgs } = input
-        const ctx = yield* InstanceState.context
-        const promptOps = yield* ops()
-        const { task: taskTool } = yield* registry.named()
-        const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
-        const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
-          id: MessageID.ascending(),
-          role: "assistant",
-          parentID: lastUser.id,
-          sessionID,
-          mode: task.agent,
+      const taskAgent = yield* agents.get(task.agent)
+      if (!taskAgent) {
+        const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
+        const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
+        const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` })
+        yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() })
+        throw error
+      }
+
+      let error: Error | undefined
+      const taskAbort = new AbortController()
+      const result = yield* taskTool
+        .execute(taskArgs, {
           agent: task.agent,
-          variant: lastUser.model.variant,
-          path: { cwd: ctx.directory, root: ctx.worktree },
-          cost: 0,
-          tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
-          modelID: taskModel.id,
-          providerID: taskModel.providerID,
-          time: { created: Date.now() },
-        })
-        let part: MessageV2.ToolPart = yield* sessions.updatePart({
-          id: PartID.ascending(),
           messageID: assistantMessage.id,
-          sessionID: assistantMessage.sessionID,
-          type: "tool",
-          callID: ulid(),
-          tool: TaskTool.id,
-          state: {
-            status: "running",
-            input: {
-              prompt: task.prompt,
-              description: task.description,
-              subagent_type: task.agent,
-              command: task.command,
-            },
-            time: { start: Date.now() },
-          },
+          sessionID,
+          abort: taskAbort.signal,
+          callID: part.callID,
+          extra: { bypassAgentCheck: true, promptOps },
+          messages: msgs,
+          metadata: (val: { title?: string; metadata?: Record<string, any> }) =>
+            Effect.gen(function* () {
+              part = yield* sessions.updatePart({
+                ...part,
+                type: "tool",
+                state: { ...part.state, ...val },
+              } satisfies MessageV2.ToolPart)
+            }),
+          ask: (req: any) =>
+            permission
+              .ask({
+                ...req,
+                sessionID,
+                ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
+              })
+              .pipe(Effect.orDie),
         })
-        const taskArgs = {
-          prompt: task.prompt,
-          description: task.description,
-          subagent_type: task.agent,
-          command: task.command,
-        }
-        yield* plugin.trigger(
-          "tool.execute.before",
-          { tool: TaskTool.id, sessionID, callID: part.id },
-          { args: taskArgs },
-        )
-
-        const taskAgent = yield* agents.get(task.agent)
-        if (!taskAgent) {
-          const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
-          const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
-          const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` })
-          yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() })
-          throw error
-        }
-
-        let error: Error | undefined
-        const taskAbort = new AbortController()
-        const result = yield* taskTool
-          .execute(taskArgs, {
-            agent: task.agent,
-            messageID: assistantMessage.id,
-            sessionID,
-            abort: taskAbort.signal,
-            callID: part.callID,
-            extra: { bypassAgentCheck: true, promptOps },
-            messages: msgs,
-            metadata: (val: { title?: string; metadata?: Record<string, any> }) =>
-              Effect.gen(function* () {
-                part = yield* sessions.updatePart({
+        .pipe(
+          Effect.catchCause((cause) => {
+            const defect = Cause.squash(cause)
+            error = defect instanceof Error ? defect : new Error(String(defect))
+            log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
+            return Effect.void
+          }),
+          Effect.onInterrupt(() =>
+            Effect.gen(function* () {
+              taskAbort.abort()
+              assistantMessage.finish = "tool-calls"
+              assistantMessage.time.completed = Date.now()
+              yield* sessions.updateMessage(assistantMessage)
+              if (part.state.status === "running") {
+                yield* sessions.updatePart({
                   ...part,
-                  type: "tool",
-                  state: { ...part.state, ...val },
+                  state: {
+                    status: "error",
+                    error: "Cancelled",
+                    time: { start: part.state.time.start, end: Date.now() },
+                    metadata: part.state.metadata,
+                    input: part.state.input,
+                  },
                 } satisfies MessageV2.ToolPart)
-              }),
-            ask: (req: any) =>
-              permission
-                .ask({
-                  ...req,
-                  sessionID,
-                  ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
-                })
-                .pipe(Effect.orDie),
-          })
-          .pipe(
-            Effect.catchCause((cause) => {
-              const defect = Cause.squash(cause)
-              error = defect instanceof Error ? defect : new Error(String(defect))
-              log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
-              return Effect.void
+              }
             }),
-            Effect.onInterrupt(() =>
-              Effect.gen(function* () {
-                taskAbort.abort()
-                assistantMessage.finish = "tool-calls"
-                assistantMessage.time.completed = Date.now()
-                yield* sessions.updateMessage(assistantMessage)
-                if (part.state.status === "running") {
-                  yield* sessions.updatePart({
-                    ...part,
-                    state: {
-                      status: "error",
-                      error: "Cancelled",
-                      time: { start: part.state.time.start, end: Date.now() },
-                      metadata: part.state.metadata,
-                      input: part.state.input,
-                    },
-                  } satisfies MessageV2.ToolPart)
-                }
-              }),
-            ),
-          )
-
-        const attachments = result?.attachments?.map((attachment) => ({
-          ...attachment,
-          id: PartID.ascending(),
-          sessionID,
-          messageID: assistantMessage.id,
-        }))
-
-        yield* plugin.trigger(
-          "tool.execute.after",
-          { tool: TaskTool.id, sessionID, callID: part.id, args: taskArgs },
-          result,
+          ),
         )
 
-        assistantMessage.finish = "tool-calls"
-        assistantMessage.time.completed = Date.now()
-        yield* sessions.updateMessage(assistantMessage)
-
-        if (result && part.state.status === "running") {
-          yield* sessions.updatePart({
-            ...part,
-            state: {
-              status: "completed",
-              input: part.state.input,
-              title: result.title,
-              metadata: result.metadata,
-              output: result.output,
-              attachments,
-              time: { ...part.state.time, end: Date.now() },
-            },
-          } satisfies MessageV2.ToolPart)
-        }
-
-        if (!result) {
-          yield* sessions.updatePart({
-            ...part,
-            state: {
-              status: "error",
-              error: error ? `Tool execution failed: ${error.message}` : "Tool execution failed",
-              time: {
-                start: part.state.status === "running" ? part.state.time.start : Date.now(),
-                end: Date.now(),
-              },
-              metadata: part.state.status === "pending" ? undefined : part.state.metadata,
-              input: part.state.input,
-            },
-          } satisfies MessageV2.ToolPart)
-        }
+      const attachments = result?.attachments?.map((attachment) => ({
+        ...attachment,
+        id: PartID.ascending(),
+        sessionID,
+        messageID: assistantMessage.id,
+      }))
+
+      yield* plugin.trigger(
+        "tool.execute.after",
+        { tool: TaskTool.id, sessionID, callID: part.id, args: taskArgs },
+        result,
+      )
 
-        if (!task.command) return
+      assistantMessage.finish = "tool-calls"
+      assistantMessage.time.completed = Date.now()
+      yield* sessions.updateMessage(assistantMessage)
 
-        const summaryUserMsg: MessageV2.User = {
-          id: MessageID.ascending(),
-          sessionID,
-          role: "user",
-          time: { created: Date.now() },
-          agent: lastUser.agent,
-          model: lastUser.model,
-        }
-        yield* sessions.updateMessage(summaryUserMsg)
+      if (result && part.state.status === "running") {
         yield* sessions.updatePart({
-          id: PartID.ascending(),
-          messageID: summaryUserMsg.id,
-          sessionID,
-          type: "text",
-          text: "Summarize the task tool output above and continue with your task.",
-          synthetic: true,
-        } satisfies MessageV2.TextPart)
-      })
-
-      const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput) {
-        const ctx = yield* InstanceState.context
-        const run = yield* runner()
-        const session = yield* sessions.get(input.sessionID)
-        if (session.revert) {
-          yield* revert.cleanup(session)
-        }
-        const agent = yield* agents.get(input.agent)
-        if (!agent) {
-          const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
-          const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
-          const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` })
-          yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
-          throw error
-        }
-        const model = input.model ?? agent.model ?? (yield* lastModel(input.sessionID))
-        const userMsg: MessageV2.User = {
-          id: input.messageID ?? MessageID.ascending(),
-          sessionID: input.sessionID,
-          time: { created: Date.now() },
-          role: "user",
-          agent: input.agent,
-          model: { providerID: model.providerID, modelID: model.modelID },
-        }
-        yield* sessions.updateMessage(userMsg)
-        const userPart: MessageV2.Part = {
-          type: "text",
-          id: PartID.ascending(),
-          messageID: userMsg.id,
-          sessionID: input.sessionID,
-          text: "The following tool was executed by the user",
-          synthetic: true,
-        }
-        yield* sessions.updatePart(userPart)
-
-        const msg: MessageV2.Assistant = {
-          id: MessageID.ascending(),
-          sessionID: input.sessionID,
-          parentID: userMsg.id,
-          mode: input.agent,
-          agent: input.agent,
-          cost: 0,
-          path: { cwd: ctx.directory, root: ctx.worktree },
-          time: { created: Date.now() },
-          role: "assistant",
-          tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
-          modelID: model.modelID,
-          providerID: model.providerID,
-        }
-        yield* sessions.updateMessage(msg)
-        const part: MessageV2.ToolPart = {
-          type: "tool",
-          id: PartID.ascending(),
-          messageID: msg.id,
-          sessionID: input.sessionID,
-          tool: "bash",
-          callID: ulid(),
+          ...part,
           state: {
-            status: "running",
-            time: { start: Date.now() },
-            input: { command: input.command },
+            status: "completed",
+            input: part.state.input,
+            title: result.title,
+            metadata: result.metadata,
+            output: result.output,
+            attachments,
+            time: { ...part.state.time, end: Date.now() },
           },
-        }
-        yield* sessions.updatePart(part)
+        } satisfies MessageV2.ToolPart)
+      }
 
-        const sh = Shell.preferred()
-        const shellName = (
-          process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh)
-        ).toLowerCase()
-        const invocations: Record<string, { args: string[] }> = {
-          nu: { args: ["-c", input.command] },
-          fish: { args: ["-c", input.command] },
-          zsh: {
-            args: [
-              "-l",
-              "-c",
-              `
-                __oc_cwd=$PWD
-                [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
-                [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
-                cd "$__oc_cwd"
-                eval ${JSON.stringify(input.command)}
-              `,
-            ],
-          },
-          bash: {
-            args: [
-              "-l",
-              "-c",
-              `
-                __oc_cwd=$PWD
-                shopt -s expand_aliases
-                [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
-                cd "$__oc_cwd"
-                eval ${JSON.stringify(input.command)}
-              `,
-            ],
+      if (!result) {
+        yield* sessions.updatePart({
+          ...part,
+          state: {
+            status: "error",
+            error: error ? `Tool execution failed: ${error.message}` : "Tool execution failed",
+            time: {
+              start: part.state.status === "running" ? part.state.time.start : Date.now(),
+              end: Date.now(),
+            },
+            metadata: part.state.status === "pending" ? undefined : part.state.metadata,
+            input: part.state.input,
           },
-          cmd: { args: ["/c", input.command] },
-          powershell: { args: ["-NoProfile", "-Command", input.command] },
-          pwsh: { args: ["-NoProfile", "-Command", input.command] },
-          "": { args: ["-c", input.command] },
-        }
+        } satisfies MessageV2.ToolPart)
+      }
+
+      if (!task.command) return
+
+      const summaryUserMsg: MessageV2.User = {
+        id: MessageID.ascending(),
+        sessionID,
+        role: "user",
+        time: { created: Date.now() },
+        agent: lastUser.agent,
+        model: lastUser.model,
+      }
+      yield* sessions.updateMessage(summaryUserMsg)
+      yield* sessions.updatePart({
+        id: PartID.ascending(),
+        messageID: summaryUserMsg.id,
+        sessionID,
+        type: "text",
+        text: "Summarize the task tool output above and continue with your task.",
+        synthetic: true,
+      } satisfies MessageV2.TextPart)
+    })
 
-        const args = (invocations[shellName] ?? invocations[""]).args
-        const cwd = ctx.directory
-        const shellEnv = yield* plugin.trigger(
-          "shell.env",
-          { cwd, sessionID: input.sessionID, callID: part.callID },
-          { env: {} },
-        )
+    const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput) {
+      const ctx = yield* InstanceState.context
+      const run = yield* runner()
+      const session = yield* sessions.get(input.sessionID)
+      if (session.revert) {
+        yield* revert.cleanup(session)
+      }
+      const agent = yield* agents.get(input.agent)
+      if (!agent) {
+        const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
+        const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
+        const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` })
+        yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
+        throw error
+      }
+      const model = input.model ?? agent.model ?? (yield* lastModel(input.sessionID))
+      const userMsg: MessageV2.User = {
+        id: input.messageID ?? MessageID.ascending(),
+        sessionID: input.sessionID,
+        time: { created: Date.now() },
+        role: "user",
+        agent: input.agent,
+        model: { providerID: model.providerID, modelID: model.modelID },
+      }
+      yield* sessions.updateMessage(userMsg)
+      const userPart: MessageV2.Part = {
+        type: "text",
+        id: PartID.ascending(),
+        messageID: userMsg.id,
+        sessionID: input.sessionID,
+        text: "The following tool was executed by the user",
+        synthetic: true,
+      }
+      yield* sessions.updatePart(userPart)
+
+      const msg: MessageV2.Assistant = {
+        id: MessageID.ascending(),
+        sessionID: input.sessionID,
+        parentID: userMsg.id,
+        mode: input.agent,
+        agent: input.agent,
+        cost: 0,
+        path: { cwd: ctx.directory, root: ctx.worktree },
+        time: { created: Date.now() },
+        role: "assistant",
+        tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+        modelID: model.modelID,
+        providerID: model.providerID,
+      }
+      yield* sessions.updateMessage(msg)
+      const part: MessageV2.ToolPart = {
+        type: "tool",
+        id: PartID.ascending(),
+        messageID: msg.id,
+        sessionID: input.sessionID,
+        tool: "bash",
+        callID: ulid(),
+        state: {
+          status: "running",
+          time: { start: Date.now() },
+          input: { command: input.command },
+        },
+      }
+      yield* sessions.updatePart(part)
+
+      const sh = Shell.preferred()
+      const shellName = (
+        process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh)
+      ).toLowerCase()
+      const invocations: Record<string, { args: string[] }> = {
+        nu: { args: ["-c", input.command] },
+        fish: { args: ["-c", input.command] },
+        zsh: {
+          args: [
+            "-l",
+            "-c",
+            `
+              __oc_cwd=$PWD
+              [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
+              [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
+              cd "$__oc_cwd"
+              eval ${JSON.stringify(input.command)}
+            `,
+          ],
+        },
+        bash: {
+          args: [
+            "-l",
+            "-c",
+            `
+              __oc_cwd=$PWD
+              shopt -s expand_aliases
+              [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
+              cd "$__oc_cwd"
+              eval ${JSON.stringify(input.command)}
+            `,
+          ],
+        },
+        cmd: { args: ["/c", input.command] },
+        powershell: { args: ["-NoProfile", "-Command", input.command] },
+        pwsh: { args: ["-NoProfile", "-Command", input.command] },
+        "": { args: ["-c", input.command] },
+      }
+
+      const args = (invocations[shellName] ?? invocations[""]).args
+      const cwd = ctx.directory
+      const shellEnv = yield* plugin.trigger(
+        "shell.env",
+        { cwd, sessionID: input.sessionID, callID: part.callID },
+        { env: {} },
+      )
 
-        const cmd = ChildProcess.make(sh, args, {
-          cwd,
-          extendEnv: true,
-          env: { ...shellEnv.env, TERM: "dumb" },
-          stdin: "ignore",
-          forceKillAfter: "3 seconds",
-        })
+      const cmd = ChildProcess.make(sh, args, {
+        cwd,
+        extendEnv: true,
+        env: { ...shellEnv.env, TERM: "dumb" },
+        stdin: "ignore",
+        forceKillAfter: "3 seconds",
+      })
 
-        let output = ""
-        let aborted = false
+      let output = ""
+      let aborted = false
 
-        const finish = Effect.uninterruptible(
-          Effect.gen(function* () {
-            if (aborted) {
-              output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
-            }
-            if (!msg.time.completed) {
-              msg.time.completed = Date.now()
-              yield* sessions.updateMessage(msg)
+      const finish = Effect.uninterruptible(
+        Effect.gen(function* () {
+          if (aborted) {
+            output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
+          }
+          if (!msg.time.completed) {
+            msg.time.completed = Date.now()
+            yield* sessions.updateMessage(msg)
+          }
+          if (part.state.status === "running") {
+            part.state = {
+              status: "completed",
+              time: { ...part.state.time, end: Date.now() },
+              input: part.state.input,
+              title: "",
+              metadata: { output, description: "" },
+              output,
             }
+            yield* sessions.updatePart(part)
+          }
+        }),
+      )
+
+      const exit = yield* Effect.gen(function* () {
+        const handle = yield* spawner.spawn(cmd)
+        yield* Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
+          Effect.sync(() => {
+            output += chunk
             if (part.state.status === "running") {
-              part.state = {
-                status: "completed",
-                time: { ...part.state.time, end: Date.now() },
-                input: part.state.input,
-                title: "",
-                metadata: { output, description: "" },
-                output,
-              }
-              yield* sessions.updatePart(part)
+              part.state.metadata = { output, description: "" }
+              void run.fork(sessions.updatePart(part))
             }
           }),
         )
+        yield* handle.exitCode
+      }).pipe(
+        Effect.scoped,
+        Effect.onInterrupt(() =>
+          Effect.sync(() => {
+            aborted = true
+          }),
+        ),
+        Effect.orDie,
+        Effect.ensuring(finish),
+        Effect.exit,
+      )
 
-        const exit = yield* Effect.gen(function* () {
-          const handle = yield* spawner.spawn(cmd)
-          yield* Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
-            Effect.sync(() => {
-              output += chunk
-              if (part.state.status === "running") {
-                part.state.metadata = { output, description: "" }
-                void run.fork(sessions.updatePart(part))
-              }
-            }),
-          )
-          yield* handle.exitCode
-        }).pipe(
-          Effect.scoped,
-          Effect.onInterrupt(() =>
-            Effect.sync(() => {
-              aborted = true
-            }),
-          ),
-          Effect.orDie,
-          Effect.ensuring(finish),
-          Effect.exit,
-        )
-
-        if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) {
-          return yield* Effect.failCause(exit.cause)
-        }
-
-        return { info: msg, parts: [part] }
-      })
-
-      const getModel = Effect.fn("SessionPrompt.getModel")(function* (
-        providerID: ProviderID,
-        modelID: ModelID,
-        sessionID: SessionID,
-      ) {
-        const exit = yield* provider.getModel(providerID, modelID).pipe(Effect.exit)
-        if (Exit.isSuccess(exit)) return exit.value
-        const err = Cause.squash(exit.cause)
-        if (Provider.ModelNotFoundError.isInstance(err)) {
-          const hint = err.data.suggestions?.length ? ` Did you mean: ${err.data.suggestions.join(", ")}?` : ""
-          yield* bus.publish(Session.Event.Error, {
-            sessionID,
-            error: new NamedError.Unknown({
-              message: `Model not found: ${err.data.providerID}/${err.data.modelID}.${hint}`,
-            }).toObject(),
-          })
-        }
+      if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) {
         return yield* Effect.failCause(exit.cause)
-      })
+      }
 
-      const lastModel = Effect.fnUntraced(function* (sessionID: SessionID) {
-        const match = yield* sessions.findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model)
-        if (Option.isSome(match) && match.value.info.role === "user") return match.value.info.model
-        return yield* provider.defaultModel()
-      })
+      return { info: msg, parts: [part] }
+    })
 
-      const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {
-        const agentName = input.agent || (yield* agents.defaultAgent())
-        const ag = yield* agents.get(agentName)
-        if (!ag) {
-          const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
-          const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
-          const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })
-          yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
-          throw error
-        }
+    const getModel = Effect.fn("SessionPrompt.getModel")(function* (
+      providerID: ProviderID,
+      modelID: ModelID,
+      sessionID: SessionID,
+    ) {
+      const exit = yield* provider.getModel(providerID, modelID).pipe(Effect.exit)
+      if (Exit.isSuccess(exit)) return exit.value
+      const err = Cause.squash(exit.cause)
+      if (Provider.ModelNotFoundError.isInstance(err)) {
+        const hint = err.data.suggestions?.length ? ` Did you mean: ${err.data.suggestions.join(", ")}?` : ""
+        yield* bus.publish(Session.Event.Error, {
+          sessionID,
+          error: new NamedError.Unknown({
+            message: `Model not found: ${err.data.providerID}/${err.data.modelID}.${hint}`,
+          }).toObject(),
+        })
+      }
+      return yield* Effect.failCause(exit.cause)
+    })
 
-        const model = input.model ?? ag.model ?? (yield* lastModel(input.sessionID))
-        const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID
-        const full =
-          !input.variant && ag.variant && same
-            ? yield* provider.getModel(model.providerID, model.modelID).pipe(Effect.catchDefect(() => Effect.void))
-            : undefined
-        const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)
-
-        const info: MessageV2.User = {
-          id: input.messageID ?? MessageID.ascending(),
-          role: "user",
-          sessionID: input.sessionID,
-          time: { created: Date.now() },
-          tools: input.tools,
-          agent: ag.name,
-          model: {
-            providerID: model.providerID,
-            modelID: model.modelID,
-            variant,
-          },
-          system: input.system,
-          format: input.format,
-        }
+    const lastModel = Effect.fnUntraced(function* (sessionID: SessionID) {
+      const match = yield* sessions.findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model)
+      if (Option.isSome(match) && match.value.info.role === "user") return match.value.info.model
+      return yield* provider.defaultModel()
+    })
 
-        yield* Effect.addFinalizer(() => instruction.clear(info.id))
+    const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {
+      const agentName = input.agent || (yield* agents.defaultAgent())
+      const ag = yield* agents.get(agentName)
+      if (!ag) {
+        const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
+        const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
+        const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })
+        yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
+        throw error
+      }
+
+      const model = input.model ?? ag.model ?? (yield* lastModel(input.sessionID))
+      const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID
+      const full =
+        !input.variant && ag.variant && same
+          ? yield* provider.getModel(model.providerID, model.modelID).pipe(Effect.catchDefect(() => Effect.void))
+          : undefined
+      const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)
+
+      const info: MessageV2.User = {
+        id: input.messageID ?? MessageID.ascending(),
+        role: "user",
+        sessionID: input.sessionID,
+        time: { created: Date.now() },
+        tools: input.tools,
+        agent: ag.name,
+        model: {
+          providerID: model.providerID,
+          modelID: model.modelID,
+          variant,
+        },
+        system: input.system,
+        format: input.format,
+      }
 
-        type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
-        const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({
-          ...part,
-          id: part.id ? PartID.make(part.id) : PartID.ascending(),
-        })
+      yield* Effect.addFinalizer(() => instruction.clear(info.id))
 
-        const resolvePart: (part: PromptInput["parts"][number]) => Effect.Effect<Draft<MessageV2.Part>[]> = Effect.fn(
-          "SessionPrompt.resolveUserPart",
-        )(function* (part) {
-          if (part.type === "file") {
-            if (part.source?.type === "resource") {
-              const { clientName, uri } = part.source
-              log.info("mcp resource", { clientName, uri, mime: part.mime })
-              const pieces: Draft<MessageV2.Part>[] = [
-                {
-                  messageID: info.id,
-                  sessionID: input.sessionID,
-                  type: "text",
-                  synthetic: true,
-                  text: `Reading MCP resource: ${part.filename} (${uri})`,
-                },
-              ]
-              const exit = yield* mcp.readResource(clientName, uri).pipe(Effect.exit)
-              if (Exit.isSuccess(exit)) {
-                const content = exit.value
-                if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`)
-                const items = Array.isArray(content.contents) ? content.contents : [content.contents]
-                for (const c of items) {
-                  if ("text" in c && c.text) {
-                    pieces.push({
-                      messageID: info.id,
-                      sessionID: input.sessionID,
-                      type: "text",
-                      synthetic: true,
-                      text: c.text,
-                    })
-                  } else if ("blob" in c && c.blob) {
-                    const mime = "mimeType" in c ? c.mimeType : part.mime
-                    pieces.push({
-                      messageID: info.id,
-                      sessionID: input.sessionID,
-                      type: "text",
-                      synthetic: true,
-                      text: `[Binary content: ${mime}]`,
-                    })
-                  }
+      type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
+      const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({
+        ...part,
+        id: part.id ? PartID.make(part.id) : PartID.ascending(),
+      })
+
+      const resolvePart: (part: PromptInput["parts"][number]) => Effect.Effect<Draft<MessageV2.Part>[]> = Effect.fn(
+        "SessionPrompt.resolveUserPart",
+      )(function* (part) {
+        if (part.type === "file") {
+          if (part.source?.type === "resource") {
+            const { clientName, uri } = part.source
+            log.info("mcp resource", { clientName, uri, mime: part.mime })
+            const pieces: Draft<MessageV2.Part>[] = [
+              {
+                messageID: info.id,
+                sessionID: input.sessionID,
+                type: "text",
+                synthetic: true,
+                text: `Reading MCP resource: ${part.filename} (${uri})`,
+              },
+            ]
+            const exit = yield* mcp.readResource(clientName, uri).pipe(Effect.exit)
+            if (Exit.isSuccess(exit)) {
+              const content = exit.value
+              if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`)
+              const items = Array.isArray(content.contents) ? content.contents : [content.contents]
+              for (const c of items) {
+                if ("text" in c && c.text) {
+                  pieces.push({
+                    messageID: info.id,
+                    sessionID: input.sessionID,
+                    type: "text",
+                    synthetic: true,
+                    text: c.text,
+                  })
+                } else if ("blob" in c && c.blob) {
+                  const mime = "mimeType" in c ? c.mimeType : part.mime
+                  pieces.push({
+                    messageID: info.id,
+                    sessionID: input.sessionID,
+                    type: "text",
+                    synthetic: true,
+                    text: `[Binary content: ${mime}]`,
+                  })
                 }
-                pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID })
-              } else {
-                const error = Cause.squash(exit.cause)
-                log.error("failed to read MCP resource", { error, clientName, uri })
-                const message = error instanceof Error ? error.message : String(error)
-                pieces.push({
-                  messageID: info.id,
-                  sessionID: input.sessionID,
-                  type: "text",
-                  synthetic: true,
-                  text: `Failed to read MCP resource ${part.filename}: ${message}`,
-                })
               }
-              return pieces
+              pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID })
+            } else {
+              const error = Cause.squash(exit.cause)
+              log.error("failed to read MCP resource", { error, clientName, uri })
+              const message = error instanceof Error ? error.message : String(error)
+              pieces.push({
+                messageID: info.id,
+                sessionID: input.sessionID,
+                type: "text",
+                synthetic: true,
+                text: `Failed to read MCP resource ${part.filename}: ${message}`,
+              })
             }
-            const url = new URL(part.url)
-            switch (url.protocol) {
-              case "data:":
-                if (part.mime === "text/plain") {
-                  return [
-                    {
-                      messageID: info.id,
-                      sessionID: input.sessionID,
-                      type: "text",
-                      synthetic: true,
-                      text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`,
-                    },
-                    {
-                      messageID: info.id,
-                      sessionID: input.sessionID,
-                      type: "text",
-                      synthetic: true,
-                      text: decodeDataUrl(part.url),
-                    },
-                    { ...part, messageID: info.id, sessionID: input.sessionID },
-                  ]
-                }
-                break
-              case "file:": {
-                log.info("file", { mime: part.mime })
-                const filepath = fileURLToPath(part.url)
-                if (yield* fsys.isDir(filepath)) part.mime = "application/x-directory"
-
-                const { read } = yield* registry.named()
-                const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => {
-                  const controller = new AbortController()
-                  return read
-                    .execute(args, {
-                      sessionID: input.sessionID,
-                      abort: controller.signal,
-                      agent: input.agent!,
-                      messageID: info.id,
-                      extra: { bypassCwdCheck: true, ...extra },
-                      messages: [],
-                      metadata: () => Effect.void,
-                      ask: () => Effect.void,
-                    })
-                    .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))
-                }
+            return pieces
+          }
+          const url = new URL(part.url)
+          switch (url.protocol) {
+            case "data:":
+              if (part.mime === "text/plain") {
+                return [
+                  {
+                    messageID: info.id,
+                    sessionID: input.sessionID,
+                    type: "text",
+                    synthetic: true,
+                    text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: part.filename })}`,
+                  },
+                  {
+                    messageID: info.id,
+                    sessionID: input.sessionID,
+                    type: "text",
+                    synthetic: true,
+                    text: decodeDataUrl(part.url),
+                  },
+                  { ...part, messageID: info.id, sessionID: input.sessionID },
+                ]
+              }
+              break
+            case "file:": {
+              log.info("file", { mime: part.mime })
+              const filepath = fileURLToPath(part.url)
+              if (yield* fsys.isDir(filepath)) part.mime = "application/x-directory"
+
+              const { read } = yield* registry.named()
+              const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => {
+                const controller = new AbortController()
+                return read
+                  .execute(args, {
+                    sessionID: input.sessionID,
+                    abort: controller.signal,
+                    agent: input.agent!,
+                    messageID: info.id,
+                    extra: { bypassCwdCheck: true, ...extra },
+                    messages: [],
+                    metadata: () => Effect.void,
+                    ask: () => Effect.void,
+                  })
+                  .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))
+              }
 
-                if (part.mime === "text/plain") {
-                  let offset: number | undefined
-                  let limit: number | undefined
-                  const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") }
-                  if (range.start != null) {
-                    const filePathURI = part.url.split("?")[0]
-                    let start = parseInt(range.start)
-                    let end = range.end ? parseInt(range.end) : undefined
-                    if (start === end) {
-                      const symbols = yield* lsp
-                        .documentSymbol(filePathURI)
-                        .pipe(Effect.catch(() => Effect.succeed([])))
-                      for (const symbol of symbols) {
-                        let r: LSP.Range | undefined
-                        if ("range" in symbol) r = symbol.range
-                        else if ("location" in symbol) r = symbol.location.range
-                        if (r?.start?.line && r?.start?.line === start) {
-                          start = r.start.line
-                          end = r?.end?.line ?? start
-                          break
-                        }
+              if (part.mime === "text/plain") {
+                let offset: number | undefined
+                let limit: number | undefined
+                const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") }
+                if (range.start != null) {
+                  const filePathURI = part.url.split("?")[0]
+                  let start = parseInt(range.start)
+                  let end = range.end ? parseInt(range.end) : undefined
+                  if (start === end) {
+                    const symbols = yield* lsp
+                      .documentSymbol(filePathURI)
+                      .pipe(Effect.catch(() => Effect.succeed([])))
+                    for (const symbol of symbols) {
+                      let r: LSP.Range | undefined
+                      if ("range" in symbol) r = symbol.range
+                      else if ("location" in symbol) r = symbol.location.range
+                      if (r?.start?.line && r?.start?.line === start) {
+                        start = r.start.line
+                        end = r?.end?.line ?? start
+                        break
                       }
                     }
-                    offset = Math.max(start, 1)
-                    if (end) limit = end - (offset - 1)
                   }
-                  const args = { filePath: filepath, offset, limit }
-                  const pieces: Draft<MessageV2.Part>[] = [
-                    {
-                      messageID: info.id,
-                      sessionID: input.sessionID,
-                      type: "text",
-                      synthetic: true,
-                      text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
-                    },
-                  ]
-                  const exit = yield* provider.getModel(info.model.providerID, info.model.modelID).pipe(
-                    Effect.flatMap((mdl) => execRead(args, { model: mdl })),
-                    Effect.exit,
-                  )
-                  if (Exit.isSuccess(exit)) {
-                    const result = exit.value
-                    pieces.push({
-                      messageID: info.id,
-                      sessionID: input.sessionID,
-                      type: "text",
-                      synthetic: true,
-                      text: result.output,
-                    })
-                    if (result.attachments?.length) {
-                      pieces.push(
-                        ...result.attachments.map((a) => ({
-                          ...a,
-                          synthetic: true,
-                          filename: a.filename ?? part.filename,
-                          messageID: info.id,
-                          sessionID: input.sessionID,
-                        })),
-                      )
-                    } else {
-                      pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID })
-                    }
-                  } else {
-                    const error = Cause.squash(exit.cause)
-                    log.error("failed to read file", { error })
-                    const message = error instanceof Error ? error.message : String(error)
-                    yield* bus.publish(Session.Event.Error, {
-                      sessionID: input.sessionID,
-                      error: new NamedError.Unknown({ message }).toObject(),
-                    })
-                    pieces.push({
-                      messageID: info.id,
-                      sessionID: input.sessionID,
-                      type: "text",
-                      synthetic: true,
-                      text: `Read tool failed to read ${filepath} with the following error: ${message}`,
-                    })
-                  }
-                  return pieces
+                  offset = Math.max(start, 1)
+                  if (end) limit = end - (offset - 1)
                 }
-
-                if (part.mime === "application/x-directory") {
-                  const args = { filePath: filepath }
-                  const exit = yield* execRead(args).pipe(Effect.exit)
-                  if (Exit.isFailure(exit)) {
-                    const error = Cause.squash(exit.cause)
-                    log.error("failed to read directory", { error })
-                    const message = error instanceof Error ? error.message : String(error)
-                    yield* bus.publish(Session.Event.Error, {
-                      sessionID: input.sessionID,
-                      error: new NamedError.Unknown({ message }).toObject(),
-                    })
-                    return [
-                      {
+                const args = { filePath: filepath, offset, limit }
+                const pieces: Draft<MessageV2.Part>[] = [
+                  {
+                    messageID: info.id,
+                    sessionID: input.sessionID,
+                    type: "text",
+                    synthetic: true,
+                    text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
+                  },
+                ]
+                const exit = yield* provider.getModel(info.model.providerID, info.model.modelID).pipe(
+                  Effect.flatMap((mdl) => execRead(args, { model: mdl })),
+                  Effect.exit,
+                )
+                if (Exit.isSuccess(exit)) {
+                  const result = exit.value
+                  pieces.push({
+                    messageID: info.id,
+                    sessionID: input.sessionID,
+                    type: "text",
+                    synthetic: true,
+                    text: result.output,
+                  })
+                  if (result.attachments?.length) {
+                    pieces.push(
+                      ...result.attachments.map((a) => ({
+                        ...a,
+                        synthetic: true,
+                        filename: a.filename ?? part.filename,
                         messageID: info.id,
                         sessionID: input.sessionID,
-                        type: "text",
-                        synthetic: true,
-                        text: `Read tool failed to read ${filepath} with the following error: ${message}`,
-                      },
-                    ]
+                      })),
+                    )
+                  } else {
+                    pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID })
                   }
+                } else {
+                  const error = Cause.squash(exit.cause)
+                  log.error("failed to read file", { error })
+                  const message = error instanceof Error ? error.message : String(error)
+                  yield* bus.publish(Session.Event.Error, {
+                    sessionID: input.sessionID,
+                    error: new NamedError.Unknown({ message }).toObject(),
+                  })
+                  pieces.push({
+                    messageID: info.id,
+                    sessionID: input.sessionID,
+                    type: "text",
+                    synthetic: true,
+                    text: `Read tool failed to read ${filepath} with the following error: ${message}`,
+                  })
+                }
+                return pieces
+              }
+
+              if (part.mime === "application/x-directory") {
+                const args = { filePath: filepath }
+                const exit = yield* execRead(args).pipe(Effect.exit)
+                if (Exit.isFailure(exit)) {
+                  const error = Cause.squash(exit.cause)
+                  log.error("failed to read directory", { error })
+                  const message = error instanceof Error ? error.message : String(error)
+                  yield* bus.publish(Session.Event.Error, {
+                    sessionID: input.sessionID,
+                    error: new NamedError.Unknown({ message }).toObject(),
+                  })
                   return [
                     {
                       messageID: info.id,
                       sessionID: input.sessionID,
                       type: "text",
                       synthetic: true,
-                      text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
-                    },
-                    {
-                      messageID: info.id,
-                      sessionID: input.sessionID,
-                      type: "text",
-                      synthetic: true,
-                      text: exit.value.output,
+                      text: `Read tool failed to read ${filepath} with the following error: ${message}`,
                     },
-                    { ...part, messageID: info.id, sessionID: input.sessionID },
                   ]
                 }
-
-                yield* filetime.read(input.sessionID, filepath)
                 return [
                   {
                     messageID: info.id,
                     sessionID: input.sessionID,
                     type: "text",
                     synthetic: true,
-                    text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`,
+                    text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
                   },
                   {
-                    id: part.id,
                     messageID: info.id,
                     sessionID: input.sessionID,
-                    type: "file",
-                    url:
-                      `data:${part.mime};base64,` +
-                      Buffer.from(yield* fsys.readFile(filepath).pipe(Effect.catch(Effect.die))).toString("base64"),
-                    mime: part.mime,
-                    filename: part.filename!,
-                    source: part.source,
+                    type: "text",
+                    synthetic: true,
+                    text: exit.value.output,
                   },
+                  { ...part, messageID: info.id, sessionID: input.sessionID },
                 ]
               }
+
+              yield* filetime.read(input.sessionID, filepath)
+              return [
+                {
+                  messageID: info.id,
+                  sessionID: input.sessionID,
+                  type: "text",
+                  synthetic: true,
+                  text: `Called the Read tool with the following input: {"filePath":"${filepath}"}`,
+                },
+                {
+                  id: part.id,
+                  messageID: info.id,
+                  sessionID: input.sessionID,
+                  type: "file",
+                  url:
+                    `data:${part.mime};base64,` +
+                    Buffer.from(yield* fsys.readFile(filepath).pipe(Effect.catch(Effect.die))).toString("base64"),
+                  mime: part.mime,
+                  filename: part.filename!,
+                  source: part.source,
+                },
+              ]
             }
           }
+        }
 
-          if (part.type === "agent") {
-            const perm = Permission.evaluate("task", part.name, ag.permission)
-            const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : ""
-            return [
-              { ...part, messageID: info.id, sessionID: input.sessionID },
-              {
-                messageID: info.id,
-                sessionID: input.sessionID,
-                type: "text",
-                synthetic: true,
-                text:
-                  " Use the above message and context to generate a prompt and call the task tool with subagent: " +
-                  part.name +
-                  hint,
-              },
-            ]
-          }
+        if (part.type === "agent") {
+          const perm = Permission.evaluate("task", part.name, ag.permission)
+          const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : ""
+          return [
+            { ...part, messageID: info.id, sessionID: input.sessionID },
+            {
+              messageID: info.id,
+              sessionID: input.sessionID,
+              type: "text",
+              synthetic: true,
+              text:
+                " Use the above message and context to generate a prompt and call the task tool with subagent: " +
+                part.name +
+                hint,
+            },
+          ]
+        }
 
-          return [{ ...part, messageID: info.id, sessionID: input.sessionID }]
-        })
+        return [{ ...part, messageID: info.id, sessionID: input.sessionID }]
+      })
 
-        const parts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(
-          Effect.map((x) => x.flat().map(assign)),
-        )
+      const parts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(
+        Effect.map((x) => x.flat().map(assign)),
+      )
 
-        yield* plugin.trigger(
-          "chat.message",
-          {
-            sessionID: input.sessionID,
-            agent: input.agent,
-            model: input.model,
-            messageID: input.messageID,
-            variant: input.variant,
-          },
-          { message: info, parts },
-        )
+      yield* plugin.trigger(
+        "chat.message",
+        {
+          sessionID: input.sessionID,
+          agent: input.agent,
+          model: input.model,
+          messageID: input.messageID,
+          variant: input.variant,
+        },
+        { message: info, parts },
+      )
 
-        const parsed = MessageV2.Info.safeParse(info)
-        if (!parsed.success) {
-          log.error("invalid user message before save", {
-            sessionID: input.sessionID,
-            messageID: info.id,
-            agent: info.agent,
-            model: info.model,
-            issues: parsed.error.issues,
-          })
-        }
-        parts.forEach((part, index) => {
-          const p = MessageV2.Part.safeParse(part)
-          if (p.success) return
-          log.error("invalid user part before save", {
-            sessionID: input.sessionID,
-            messageID: info.id,
-            partID: part.id,
-            partType: part.type,
-            index,
-            issues: p.error.issues,
-            part,
-          })
+      const parsed = MessageV2.Info.safeParse(info)
+      if (!parsed.success) {
+        log.error("invalid user message before save", {
+          sessionID: input.sessionID,
+          messageID: info.id,
+          agent: info.agent,
+          model: info.model,
+          issues: parsed.error.issues,
         })
+      }
+      parts.forEach((part, index) => {
+        const p = MessageV2.Part.safeParse(part)
+        if (p.success) return
+        log.error("invalid user part before save", {
+          sessionID: input.sessionID,
+          messageID: info.id,
+          partID: part.id,
+          partType: part.type,
+          index,
+          issues: p.error.issues,
+          part,
+        })
+      })
 
-        yield* sessions.updateMessage(info)
-        for (const part of parts) yield* sessions.updatePart(part)
+      yield* sessions.updateMessage(info)
+      for (const part of parts) yield* sessions.updatePart(part)
 
-        return { info, parts }
-      }, Effect.scoped)
+      return { info, parts }
+    }, Effect.scoped)
 
-      const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")(
-        function* (input: PromptInput) {
-          const session = yield* sessions.get(input.sessionID)
-          yield* revert.cleanup(session)
-          const message = yield* createUserMessage(input)
-          yield* sessions.touch(input.sessionID)
+    const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")(
+      function* (input: PromptInput) {
+        const session = yield* sessions.get(input.sessionID)
+        yield* revert.cleanup(session)
+        const message = yield* createUserMessage(input)
+        yield* sessions.touch(input.sessionID)
 
-          const permissions: Permission.Ruleset = []
-          for (const [t, enabled] of Object.entries(input.tools ?? {})) {
-            permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
-          }
-          if (permissions.length > 0) {
-            session.permission = permissions
-            yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
+        const permissions: Permission.Ruleset = []
+        for (const [t, enabled] of Object.entries(input.tools ?? {})) {
+          permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
+        }
+        if (permissions.length > 0) {
+          session.permission = permissions
+          yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
+        }
+
+        if (input.noReply === true) return message
+        return yield* loop({ sessionID: input.sessionID })
+      },
+    )
+
+    const lastAssistant = Effect.fnUntraced(function* (sessionID: SessionID) {
+      const match = yield* sessions.findMessage(sessionID, (m) => m.info.role !== "user")
+      if (Option.isSome(match)) return match.value
+      const msgs = yield* sessions.messages({ sessionID, limit: 1 })
+      if (msgs.length > 0) return msgs[0]
+      throw new Error("Impossible")
+    })
+
+    const runLoop: (sessionID: SessionID) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.run")(
+      function* (sessionID: SessionID) {
+        const ctx = yield* InstanceState.context
+        const slog = elog.with({ sessionID })
+        let structured: unknown | undefined
+        let step = 0
+        const session = yield* sessions.get(sessionID)
+
+        while (true) {
+          yield* status.set(sessionID, { type: "busy" })
+          yield* slog.info("loop", { step })
+
+          let msgs = yield* MessageV2.filterCompactedEffect(sessionID)
+
+          let lastUser: MessageV2.User | undefined
+          let lastAssistant: MessageV2.Assistant | undefined
+          let lastFinished: MessageV2.Assistant | undefined
+          let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = []
+          for (let i = msgs.length - 1; i >= 0; i--) {
+            const msg = msgs[i]
+            if (!lastUser && msg.info.role === "user") lastUser = msg.info
+            if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info
+            if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info
+            if (lastUser && lastFinished) break
+            const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask")
+            if (task && !lastFinished) tasks.push(...task)
           }
 
-          if (input.noReply === true) return message
-          return yield* loop({ sessionID: input.sessionID })
-        },
-      )
+          if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
 
-      const lastAssistant = Effect.fnUntraced(function* (sessionID: SessionID) {
-        const match = yield* sessions.findMessage(sessionID, (m) => m.info.role !== "user")
-        if (Option.isSome(match)) return match.value
-        const msgs = yield* sessions.messages({ sessionID, limit: 1 })
-        if (msgs.length > 0) return msgs[0]
-        throw new Error("Impossible")
-      })
+          const lastAssistantMsg = msgs.findLast(
+            (msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,
+          )
+          // Some providers return "stop" even when the assistant message contains tool calls.
+          // Keep the loop running so tool results can be sent back to the model.
+          // Skip provider-executed tool parts — those were fully handled within the
+          // provider's stream (e.g. DWS Agent Platform) and don't need a re-loop.
+          const hasToolCalls =
+            lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false
+
+          if (
+            lastAssistant?.finish &&
+            !["tool-calls"].includes(lastAssistant.finish) &&
+            !hasToolCalls &&
+            lastUser.id < lastAssistant.id
+          ) {
+            yield* slog.info("exiting loop")
+            break
+          }
 
-      const runLoop: (sessionID: SessionID) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.run")(
-        function* (sessionID: SessionID) {
-          const ctx = yield* InstanceState.context
-          const slog = elog.with({ sessionID })
-          let structured: unknown | undefined
-          let step = 0
-          const session = yield* sessions.get(sessionID)
-
-          while (true) {
-            yield* status.set(sessionID, { type: "busy" })
-            yield* slog.info("loop", { step })
-
-            let msgs = yield* MessageV2.filterCompactedEffect(sessionID)
-
-            let lastUser: MessageV2.User | undefined
-            let lastAssistant: MessageV2.Assistant | undefined
-            let lastFinished: MessageV2.Assistant | undefined
-            let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = []
-            for (let i = msgs.length - 1; i >= 0; i--) {
-              const msg = msgs[i]
-              if (!lastUser && msg.info.role === "user") lastUser = msg.info
-              if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info
-              if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info
-              if (lastUser && lastFinished) break
-              const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask")
-              if (task && !lastFinished) tasks.push(...task)
-            }
+          step++
+          if (step === 1)
+            yield* title({
+              session,
+              modelID: lastUser.model.modelID,
+              providerID: lastUser.model.providerID,
+              history: msgs,
+            }).pipe(Effect.ignore, Effect.forkIn(scope))
 
-            if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
+          const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID)
+          const task = tasks.pop()
 
-            const lastAssistantMsg = msgs.findLast(
-              (msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,
-            )
-            // Some providers return "stop" even when the assistant message contains tool calls.
-            // Keep the loop running so tool results can be sent back to the model.
-            // Skip provider-executed tool parts — those were fully handled within the
-            // provider's stream (e.g. DWS Agent Platform) and don't need a re-loop.
-            const hasToolCalls =
-              lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false
-
-            if (
-              lastAssistant?.finish &&
-              !["tool-calls"].includes(lastAssistant.finish) &&
-              !hasToolCalls &&
-              lastUser.id < lastAssistant.id
-            ) {
-              yield* slog.info("exiting loop")
-              break
-            }
+          if (task?.type === "subtask") {
+            yield* handleSubtask({ task, model, lastUser, sessionID, session, msgs })
+            continue
+          }
 
-            step++
-            if (step === 1)
-              yield* title({
-                session,
-                modelID: lastUser.model.modelID,
-                providerID: lastUser.model.providerID,
-                history: msgs,
-              }).pipe(Effect.ignore, Effect.forkIn(scope))
-
-            const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID)
-            const task = tasks.pop()
-
-            if (task?.type === "subtask") {
-              yield* handleSubtask({ task, model, lastUser, sessionID, session, msgs })
-              continue
-            }
+          if (task?.type === "compaction") {
+            const result = yield* compaction.process({
+              messages: msgs,
+              parentID: lastUser.id,
+              sessionID,
+              auto: task.auto,
+              overflow: task.overflow,
+            })
+            if (result === "stop") break
+            continue
+          }
 
-            if (task?.type === "compaction") {
-              const result = yield* compaction.process({
-                messages: msgs,
-                parentID: lastUser.id,
-                sessionID,
-                auto: task.auto,
-                overflow: task.overflow,
-              })
-              if (result === "stop") break
-              continue
-            }
+          if (
+            lastFinished &&
+            lastFinished.summary !== true &&
+            (yield* compaction.isOverflow({ tokens: lastFinished.tokens, model }))
+          ) {
+            yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })
+            continue
+          }
 
-            if (
-              lastFinished &&
-              lastFinished.summary !== true &&
-              (yield* compaction.isOverflow({ tokens: lastFinished.tokens, model }))
-            ) {
-              yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })
-              continue
-            }
+          const agent = yield* agents.get(lastUser.agent)
+          if (!agent) {
+            const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
+            const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
+            const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` })
+            yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() })
+            throw error
+          }
+          const maxSteps = agent.steps ?? Infinity
+          const isLastStep = step >= maxSteps
+          msgs = yield* insertReminders({ messages: msgs, agent, session })
+
+          const msg: MessageV2.Assistant = {
+            id: MessageID.ascending(),
+            parentID: lastUser.id,
+            role: "assistant",
+            mode: agent.name,
+            agent: agent.name,
+            variant: lastUser.model.variant,
+            path: { cwd: ctx.directory, root: ctx.worktree },
+            cost: 0,
+            tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+            modelID: model.id,
+            providerID: model.providerID,
+            time: { created: Date.now() },
+            sessionID,
+          }
+          yield* sessions.updateMessage(msg)
+          const handle = yield* processor.create({
+            assistantMessage: msg,
+            sessionID,
+            model,
+          })
 
-            const agent = yield* agents.get(lastUser.agent)
-            if (!agent) {
-              const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
-              const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
-              const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` })
-              yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() })
-              throw error
-            }
-            const maxSteps = agent.steps ?? Infinity
-            const isLastStep = step >= maxSteps
-            msgs = yield* insertReminders({ messages: msgs, agent, session })
+          const outcome: "break" | "continue" = yield* Effect.gen(function* () {
+            const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
+            const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
 
-            const msg: MessageV2.Assistant = {
-              id: MessageID.ascending(),
-              parentID: lastUser.id,
-              role: "assistant",
-              mode: agent.name,
-              agent: agent.name,
-              variant: lastUser.model.variant,
-              path: { cwd: ctx.directory, root: ctx.worktree },
-              cost: 0,
-              tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
-              modelID: model.id,
-              providerID: model.providerID,
-              time: { created: Date.now() },
-              sessionID,
-            }
-            yield* sessions.updateMessage(msg)
-            const handle = yield* processor.create({
-              assistantMessage: msg,
-              sessionID,
+            const tools = yield* resolveTools({
+              agent,
+              session,
               model,
+              tools: lastUser.tools,
+              processor: handle,
+              bypassAgentCheck,
+              messages: msgs,
             })
 
-            const outcome: "break" | "continue" = yield* Effect.gen(function* () {
-              const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
-              const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
-
-              const tools = yield* resolveTools({
-                agent,
-                session,
-                model,
-                tools: lastUser.tools,
-                processor: handle,
-                bypassAgentCheck,
-                messages: msgs,
+            if (lastUser.format?.type === "json_schema") {
+              tools["StructuredOutput"] = createStructuredOutputTool({
+                schema: lastUser.format.schema,
+                onSuccess(output) {
+                  structured = output
+                },
               })
+            }
 
-              if (lastUser.format?.type === "json_schema") {
-                tools["StructuredOutput"] = createStructuredOutputTool({
-                  schema: lastUser.format.schema,
-                  onSuccess(output) {
-                    structured = output
-                  },
-                })
-              }
-
-              if (step === 1)
-                yield* summary
-                  .summarize({ sessionID, messageID: lastUser.id })
-                  .pipe(Effect.ignore, Effect.forkIn(scope))
-
-              if (step > 1 && lastFinished) {
-                for (const m of msgs) {
-                  if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue
-                  for (const p of m.parts) {
-                    if (p.type !== "text" || p.ignored || p.synthetic) continue
-                    if (!p.text.trim()) continue
-                    p.text = [
-                      "<system-reminder>",
-                      "The user sent the following message:",
-                      p.text,
-                      "",
-                      "Please address this message and continue with your tasks.",
-                      "</system-reminder>",
-                    ].join("\n")
-                  }
+            if (step === 1)
+              yield* summary
+                .summarize({ sessionID, messageID: lastUser.id })
+                .pipe(Effect.ignore, Effect.forkIn(scope))
+
+            if (step > 1 && lastFinished) {
+              for (const m of msgs) {
+                if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue
+                for (const p of m.parts) {
+                  if (p.type !== "text" || p.ignored || p.synthetic) continue
+                  if (!p.text.trim()) continue
+                  p.text = [
+                    "<system-reminder>",
+                    "The user sent the following message:",
+                    p.text,
+                    "",
+                    "Please address this message and continue with your tasks.",
+                    "</system-reminder>",
+                  ].join("\n")
                 }
               }
+            }
 
-              yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
-
-              const [skills, env, instructions, modelMsgs] = yield* Effect.all([
-                sys.skills(agent),
-                Effect.sync(() => sys.environment(model)),
-                instruction.system().pipe(Effect.orDie),
-                MessageV2.toModelMessagesEffect(msgs, model),
-              ])
-              const system = [...env, ...(skills ? [skills] : []), ...instructions]
-              const format = lastUser.format ?? { type: "text" as const }
-              if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
-              const result = yield* handle.process({
-                user: lastUser,
-                agent,
-                permission: session.permission,
-                sessionID,
-                parentSessionID: session.parentID,
-                system,
-                messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])],
-                tools,
-                model,
-                toolChoice: format.type === "json_schema" ? "required" : undefined,
-              })
+            yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
+
+            const [skills, env, instructions, modelMsgs] = yield* Effect.all([
+              sys.skills(agent),
+              Effect.sync(() => sys.environment(model)),
+              instruction.system().pipe(Effect.orDie),
+              MessageV2.toModelMessagesEffect(msgs, model),
+            ])
+            const system = [...env, ...(skills ? [skills] : []), ...instructions]
+            const format = lastUser.format ?? { type: "text" as const }
+            if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
+            const result = yield* handle.process({
+              user: lastUser,
+              agent,
+              permission: session.permission,
+              sessionID,
+              parentSessionID: session.parentID,
+              system,
+              messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])],
+              tools,
+              model,
+              toolChoice: format.type === "json_schema" ? "required" : undefined,
+            })
+
+            if (structured !== undefined) {
+              handle.message.structured = structured
+              handle.message.finish = handle.message.finish ?? "stop"
+              yield* sessions.updateMessage(handle.message)
+              return "break" as const
+            }
 
-              if (structured !== undefined) {
-                handle.message.structured = structured
-                handle.message.finish = handle.message.finish ?? "stop"
+            const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish)
+            if (finished && !handle.message.error) {
+              if (format.type === "json_schema") {
+                handle.message.error = new MessageV2.StructuredOutputError({
+                  message: "Model did not produce structured output",
+                  retries: 0,
+                }).toObject()
                 yield* sessions.updateMessage(handle.message)
                 return "break" as const
               }
+            }
 
-              const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish)
-              if (finished && !handle.message.error) {
-                if (format.type === "json_schema") {
-                  handle.message.error = new MessageV2.StructuredOutputError({
-                    message: "Model did not produce structured output",
-                    retries: 0,
-                  }).toObject()
-                  yield* sessions.updateMessage(handle.message)
-                  return "break" as const
-                }
-              }
-
-              if (result === "stop") return "break" as const
-              if (result === "compact") {
-                yield* compaction.create({
-                  sessionID,
-                  agent: lastUser.agent,
-                  model: lastUser.model,
-                  auto: true,
-                  overflow: !handle.message.finish,
-                })
-              }
-              return "continue" as const
-            }).pipe(Effect.ensuring(instruction.clear(handle.message.id)))
-            if (outcome === "break") break
-            continue
-          }
-
-          yield* compaction.prune({ sessionID }).pipe(Effect.ignore, Effect.forkIn(scope))
-          return yield* lastAssistant(sessionID)
-        },
-      )
-
-      const loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts> = Effect.fn(
-        "SessionPrompt.loop",
-      )(function* (input: z.infer<typeof LoopInput>) {
-        return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID))
-      })
-
-      const shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.shell")(
-        function* (input: ShellInput) {
-          return yield* state.startShell(input.sessionID, lastAssistant(input.sessionID), shellImpl(input))
-        },
-      )
-
-      const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) {
-        yield* elog.info("command", { sessionID: input.sessionID, command: input.command, agent: input.agent })
-        const cmd = yield* commands.get(input.command)
-        if (!cmd) {
-          const available = (yield* commands.list()).map((c) => c.name)
-          const hint = available.length ? ` Available commands: ${available.join(", ")}` : ""
-          const error = new NamedError.Unknown({ message: `Command not found: "${input.command}".${hint}` })
-          yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
-          throw error
-        }
-        const agentName = cmd.agent ?? input.agent ?? (yield* agents.defaultAgent())
-
-        const raw = input.arguments.match(argsRegex) ?? []
-        const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
-        const templateCommand = yield* Effect.promise(async () => cmd.template)
-
-        const placeholders = templateCommand.match(placeholderRegex) ?? []
-        let last = 0
-        for (const item of placeholders) {
-          const value = Number(item.slice(1))
-          if (value > last) last = value
+            if (result === "stop") return "break" as const
+            if (result === "compact") {
+              yield* compaction.create({
+                sessionID,
+                agent: lastUser.agent,
+                model: lastUser.model,
+                auto: true,
+                overflow: !handle.message.finish,
+              })
+            }
+            return "continue" as const
+          }).pipe(Effect.ensuring(instruction.clear(handle.message.id)))
+          if (outcome === "break") break
+          continue
         }
 
-        const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => {
-          const position = Number(index)
-          const argIndex = position - 1
-          if (argIndex >= args.length) return ""
-          if (position === last) return args.slice(argIndex).join(" ")
-          return args[argIndex]
-        })
-        const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS")
-        let template = withArgs.replaceAll("$ARGUMENTS", input.arguments)
-
-        if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) {
-          template = template + "\n\n" + input.arguments
-        }
+        yield* compaction.prune({ sessionID }).pipe(Effect.ignore, Effect.forkIn(scope))
+        return yield* lastAssistant(sessionID)
+      },
+    )
 
-        const shellMatches = ConfigMarkdown.shell(template)
-        if (shellMatches.length > 0) {
-          const sh = Shell.preferred()
-          const results = yield* Effect.promise(() =>
-            Promise.all(
-              shellMatches.map(async ([, cmd]) => (await Process.text([cmd], { shell: sh, nothrow: true })).text),
-            ),
-          )
-          let index = 0
-          template = template.replace(bashRegex, () => results[index++])
-        }
-        template = template.trim()
+    const loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts> = Effect.fn(
+      "SessionPrompt.loop",
+    )(function* (input: z.infer<typeof LoopInput>) {
+      return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID))
+    })
 
-        const taskModel = yield* Effect.gen(function* () {
-          if (cmd.model) return Provider.parseModel(cmd.model)
-          if (cmd.agent) {
-            const cmdAgent = yield* agents.get(cmd.agent)
-            if (cmdAgent?.model) return cmdAgent.model
-          }
-          if (input.model) return Provider.parseModel(input.model)
-          return yield* lastModel(input.sessionID)
-        })
+    const shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.shell")(
+      function* (input: ShellInput) {
+        return yield* state.startShell(input.sessionID, lastAssistant(input.sessionID), shellImpl(input))
+      },
+    )
+
+    const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) {
+      yield* elog.info("command", { sessionID: input.sessionID, command: input.command, agent: input.agent })
+      const cmd = yield* commands.get(input.command)
+      if (!cmd) {
+        const available = (yield* commands.list()).map((c) => c.name)
+        const hint = available.length ? ` Available commands: ${available.join(", ")}` : ""
+        const error = new NamedError.Unknown({ message: `Command not found: "${input.command}".${hint}` })
+        yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
+        throw error
+      }
+      const agentName = cmd.agent ?? input.agent ?? (yield* agents.defaultAgent())
+
+      const raw = input.arguments.match(argsRegex) ?? []
+      const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
+      const templateCommand = yield* Effect.promise(async () => cmd.template)
+
+      const placeholders = templateCommand.match(placeholderRegex) ?? []
+      let last = 0
+      for (const item of placeholders) {
+        const value = Number(item.slice(1))
+        if (value > last) last = value
+      }
+
+      const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => {
+        const position = Number(index)
+        const argIndex = position - 1
+        if (argIndex >= args.length) return ""
+        if (position === last) return args.slice(argIndex).join(" ")
+        return args[argIndex]
+      })
+      const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS")
+      let template = withArgs.replaceAll("$ARGUMENTS", input.arguments)
 
-        yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID)
+      if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) {
+        template = template + "\n\n" + input.arguments
+      }
 
-        const agent = yield* agents.get(agentName)
-        if (!agent) {
-          const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
-          const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
-          const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })
-          yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
-          throw error
+      const shellMatches = ConfigMarkdown.shell(template)
+      if (shellMatches.length > 0) {
+        const sh = Shell.preferred()
+        const results = yield* Effect.promise(() =>
+          Promise.all(
+            shellMatches.map(async ([, cmd]) => (await Process.text([cmd], { shell: sh, nothrow: true })).text),
+          ),
+        )
+        let index = 0
+        template = template.replace(bashRegex, () => results[index++])
+      }
+      template = template.trim()
+
+      const taskModel = yield* Effect.gen(function* () {
+        if (cmd.model) return Provider.parseModel(cmd.model)
+        if (cmd.agent) {
+          const cmdAgent = yield* agents.get(cmd.agent)
+          if (cmdAgent?.model) return cmdAgent.model
         }
+        if (input.model) return Provider.parseModel(input.model)
+        return yield* lastModel(input.sessionID)
+      })
 
-        const templateParts = yield* resolvePromptParts(template)
-        const isSubtask = (agent.mode === "subagent" && cmd.subtask !== false) || cmd.subtask === true
-        const parts = isSubtask
-          ? [
-              {
-                type: "subtask" as const,
-                agent: agent.name,
-                description: cmd.description ?? "",
-                command: input.command,
-                model: { providerID: taskModel.providerID, modelID: taskModel.modelID },
-                prompt: templateParts.find((y) => y.type === "text")?.text ?? "",
-              },
-            ]
-          : [...templateParts, ...(input.parts ?? [])]
-
-        const userAgent = isSubtask ? (input.agent ?? (yield* agents.defaultAgent())) : agentName
-        const userModel = isSubtask
-          ? input.model
-            ? Provider.parseModel(input.model)
-            : yield* lastModel(input.sessionID)
-          : taskModel
-
-        yield* plugin.trigger(
-          "command.execute.before",
-          { command: input.command, sessionID: input.sessionID, arguments: input.arguments },
-          { parts },
-        )
+      yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID)
+
+      const agent = yield* agents.get(agentName)
+      if (!agent) {
+        const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
+        const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
+        const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })
+        yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
+        throw error
+      }
+
+      const templateParts = yield* resolvePromptParts(template)
+      const isSubtask = (agent.mode === "subagent" && cmd.subtask !== false) || cmd.subtask === true
+      const parts = isSubtask
+        ? [
+            {
+              type: "subtask" as const,
+              agent: agent.name,
+              description: cmd.description ?? "",
+              command: input.command,
+              model: { providerID: taskModel.providerID, modelID: taskModel.modelID },
+              prompt: templateParts.find((y) => y.type === "text")?.text ?? "",
+            },
+          ]
+        : [...templateParts, ...(input.parts ?? [])]
+
+      const userAgent = isSubtask ? (input.agent ?? (yield* agents.defaultAgent())) : agentName
+      const userModel = isSubtask
+        ? input.model
+          ? Provider.parseModel(input.model)
+          : yield* lastModel(input.sessionID)
+        : taskModel
+
+      yield* plugin.trigger(
+        "command.execute.before",
+        { command: input.command, sessionID: input.sessionID, arguments: input.arguments },
+        { parts },
+      )
 
-        const result = yield* prompt({
-          sessionID: input.sessionID,
-          messageID: input.messageID,
-          model: userModel,
-          agent: userAgent,
-          parts,
-          variant: input.variant,
-        })
-        yield* bus.publish(Command.Event.Executed, {
-          name: input.command,
-          sessionID: input.sessionID,
-          arguments: input.arguments,
-          messageID: result.info.id,
-        })
-        return result
+      const result = yield* prompt({
+        sessionID: input.sessionID,
+        messageID: input.messageID,
+        model: userModel,
+        agent: userAgent,
+        parts,
+        variant: input.variant,
       })
-
-      return Service.of({
-        cancel,
-        prompt,
-        loop,
-        shell,
-        command,
-        resolvePromptParts,
+      yield* bus.publish(Command.Event.Executed, {
+        name: input.command,
+        sessionID: input.sessionID,
+        arguments: input.arguments,
+        messageID: result.info.id,
       })
-    }),
-  )
-
-  export const defaultLayer = Layer.suspend(() =>
-    layer.pipe(
-      Layer.provide(SessionRunState.defaultLayer),
-      Layer.provide(SessionStatus.defaultLayer),
-      Layer.provide(SessionCompaction.defaultLayer),
-      Layer.provide(SessionProcessor.defaultLayer),
-      Layer.provide(Command.defaultLayer),
-      Layer.provide(Permission.defaultLayer),
-      Layer.provide(MCP.defaultLayer),
-      Layer.provide(LSP.defaultLayer),
-      Layer.provide(FileTime.defaultLayer),
-      Layer.provide(ToolRegistry.defaultLayer),
-      Layer.provide(Truncate.defaultLayer),
-      Layer.provide(Provider.defaultLayer),
-      Layer.provide(Instruction.defaultLayer),
-      Layer.provide(AppFileSystem.defaultLayer),
-      Layer.provide(Plugin.defaultLayer),
-      Layer.provide(Session.defaultLayer),
-      Layer.provide(SessionRevert.defaultLayer),
-      Layer.provide(SessionSummary.defaultLayer),
-      Layer.provide(
-        Layer.mergeAll(
-          Agent.defaultLayer,
-          SystemPrompt.defaultLayer,
-          LLM.defaultLayer,
-          Bus.layer,
-          CrossSpawnSpawner.defaultLayer,
-        ),
+      return result
+    })
+
+    return Service.of({
+      cancel,
+      prompt,
+      loop,
+      shell,
+      command,
+      resolvePromptParts,
+    })
+  }),
+)
+
+export const defaultLayer = Layer.suspend(() =>
+  layer.pipe(
+    Layer.provide(SessionRunState.defaultLayer),
+    Layer.provide(SessionStatus.defaultLayer),
+    Layer.provide(SessionCompaction.defaultLayer),
+    Layer.provide(SessionProcessor.defaultLayer),
+    Layer.provide(Command.defaultLayer),
+    Layer.provide(Permission.defaultLayer),
+    Layer.provide(MCP.defaultLayer),
+    Layer.provide(LSP.defaultLayer),
+    Layer.provide(FileTime.defaultLayer),
+    Layer.provide(ToolRegistry.defaultLayer),
+    Layer.provide(Truncate.defaultLayer),
+    Layer.provide(Provider.defaultLayer),
+    Layer.provide(Instruction.defaultLayer),
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(Plugin.defaultLayer),
+    Layer.provide(Session.defaultLayer),
+    Layer.provide(SessionRevert.defaultLayer),
+    Layer.provide(SessionSummary.defaultLayer),
+    Layer.provide(
+      Layer.mergeAll(
+        Agent.defaultLayer,
+        SystemPrompt.defaultLayer,
+        LLM.defaultLayer,
+        Bus.layer,
+        CrossSpawnSpawner.defaultLayer,
       ),
     ),
-  )
-  export const PromptInput = z.object({
-    sessionID: SessionID.zod,
-    messageID: MessageID.zod.optional(),
-    model: z
-      .object({
-        providerID: ProviderID.zod,
-        modelID: ModelID.zod,
+  ),
+)
+export const PromptInput = z.object({
+  sessionID: SessionID.zod,
+  messageID: MessageID.zod.optional(),
+  model: z
+    .object({
+      providerID: ProviderID.zod,
+      modelID: ModelID.zod,
+    })
+    .optional(),
+  agent: z.string().optional(),
+  noReply: z.boolean().optional(),
+  tools: z
+    .record(z.string(), z.boolean())
+    .optional()
+    .describe(
+      "@deprecated tools and permissions have been merged, you can set permissions on the session itself now",
+    ),
+  format: MessageV2.Format.optional(),
+  system: z.string().optional(),
+  variant: z.string().optional(),
+  parts: z.array(
+    z.discriminatedUnion("type", [
+      MessageV2.TextPart.omit({
+        messageID: true,
+        sessionID: true,
       })
-      .optional(),
-    agent: z.string().optional(),
-    noReply: z.boolean().optional(),
-    tools: z
-      .record(z.string(), z.boolean())
-      .optional()
-      .describe(
-        "@deprecated tools and permissions have been merged, you can set permissions on the session itself now",
-      ),
-    format: MessageV2.Format.optional(),
-    system: z.string().optional(),
-    variant: z.string().optional(),
-    parts: z.array(
-      z.discriminatedUnion("type", [
-        MessageV2.TextPart.omit({
-          messageID: true,
-          sessionID: true,
+        .partial({
+          id: true,
         })
-          .partial({
-            id: true,
-          })
-          .meta({
-            ref: "TextPartInput",
-          }),
-        MessageV2.FilePart.omit({
-          messageID: true,
-          sessionID: true,
+        .meta({
+          ref: "TextPartInput",
+        }),
+      MessageV2.FilePart.omit({
+        messageID: true,
+        sessionID: true,
+      })
+        .partial({
+          id: true,
         })
-          .partial({
-            id: true,
-          })
-          .meta({
-            ref: "FilePartInput",
-          }),
-        MessageV2.AgentPart.omit({
-          messageID: true,
-          sessionID: true,
+        .meta({
+          ref: "FilePartInput",
+        }),
+      MessageV2.AgentPart.omit({
+        messageID: true,
+        sessionID: true,
+      })
+        .partial({
+          id: true,
         })
-          .partial({
-            id: true,
-          })
-          .meta({
-            ref: "AgentPartInput",
-          }),
-        MessageV2.SubtaskPart.omit({
+        .meta({
+          ref: "AgentPartInput",
+        }),
+      MessageV2.SubtaskPart.omit({
+        messageID: true,
+        sessionID: true,
+      })
+        .partial({
+          id: true,
+        })
+        .meta({
+          ref: "SubtaskPartInput",
+        }),
+    ]),
+  ),
+})
+export type PromptInput = z.infer<typeof PromptInput>
+
+export const LoopInput = z.object({
+  sessionID: SessionID.zod,
+})
+
+export const ShellInput = z.object({
+  sessionID: SessionID.zod,
+  messageID: MessageID.zod.optional(),
+  agent: z.string(),
+  model: z
+    .object({
+      providerID: ProviderID.zod,
+      modelID: ModelID.zod,
+    })
+    .optional(),
+  command: z.string(),
+})
+export type ShellInput = z.infer<typeof ShellInput>
+
+export const CommandInput = z.object({
+  messageID: MessageID.zod.optional(),
+  sessionID: SessionID.zod,
+  agent: z.string().optional(),
+  model: z.string().optional(),
+  arguments: z.string(),
+  command: z.string(),
+  variant: z.string().optional(),
+  parts: z
+    .array(
+      z.discriminatedUnion("type", [
+        MessageV2.FilePart.omit({
           messageID: true,
           sessionID: true,
-        })
-          .partial({
-            id: true,
-          })
-          .meta({
-            ref: "SubtaskPartInput",
-          }),
+        }).partial({
+          id: true,
+        }),
       ]),
-    ),
-  })
-  export type PromptInput = z.infer<typeof PromptInput>
-
-  export const LoopInput = z.object({
-    sessionID: SessionID.zod,
-  })
-
-  export const ShellInput = z.object({
-    sessionID: SessionID.zod,
-    messageID: MessageID.zod.optional(),
-    agent: z.string(),
-    model: z
-      .object({
-        providerID: ProviderID.zod,
-        modelID: ModelID.zod,
-      })
-      .optional(),
-    command: z.string(),
-  })
-  export type ShellInput = z.infer<typeof ShellInput>
-
-  export const CommandInput = z.object({
-    messageID: MessageID.zod.optional(),
-    sessionID: SessionID.zod,
-    agent: z.string().optional(),
-    model: z.string().optional(),
-    arguments: z.string(),
-    command: z.string(),
-    variant: z.string().optional(),
-    parts: z
-      .array(
-        z.discriminatedUnion("type", [
-          MessageV2.FilePart.omit({
-            messageID: true,
-            sessionID: true,
-          }).partial({
-            id: true,
-          }),
-        ]),
-      )
-      .optional(),
+    )
+    .optional(),
+})
+export type CommandInput = z.infer<typeof CommandInput>
+
+/** @internal Exported for testing */
+export function createStructuredOutputTool(input: {
+  schema: Record<string, any>
+  onSuccess: (output: unknown) => void
+}): AITool {
+  // Remove $schema property if present (not needed for tool input)
+  const { $schema: _, ...toolSchema } = input.schema
+
+  return tool({
+    id: "StructuredOutput" as any,
+    description: STRUCTURED_OUTPUT_DESCRIPTION,
+    inputSchema: jsonSchema(toolSchema as any),
+    async execute(args) {
+      // AI SDK validates args against inputSchema before calling execute()
+      input.onSuccess(args)
+      return {
+        output: "Structured output captured successfully.",
+        title: "Structured Output",
+        metadata: { valid: true },
+      }
+    },
+    toModelOutput({ output }) {
+      return {
+        type: "text",
+        value: output.output,
+      }
+    },
   })
-  export type CommandInput = z.infer<typeof CommandInput>
-
-  /** @internal Exported for testing */
-  export function createStructuredOutputTool(input: {
-    schema: Record<string, any>
-    onSuccess: (output: unknown) => void
-  }): AITool {
-    // Remove $schema property if present (not needed for tool input)
-    const { $schema: _, ...toolSchema } = input.schema
-
-    return tool({
-      id: "StructuredOutput" as any,
-      description: STRUCTURED_OUTPUT_DESCRIPTION,
-      inputSchema: jsonSchema(toolSchema as any),
-      async execute(args) {
-        // AI SDK validates args against inputSchema before calling execute()
-        input.onSuccess(args)
-        return {
-          output: "Structured output captured successfully.",
-          title: "Structured Output",
-          metadata: { valid: true },
-        }
-      },
-      toModelOutput({ output }) {
-        return {
-          type: "text",
-          value: output.output,
-        }
-      },
-    })
-  }
-  const bashRegex = /!`([^`]+)`/g
-  // Match [Image N] as single token, quoted strings, or non-space sequences
-  const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
-  const placeholderRegex = /\$(\d+)/g
-  const quoteTrimRegex = /^["']|["']$/g
 }
+const bashRegex = /!`([^`]+)`/g
+// Match [Image N] as single token, quoted strings, or non-space sequences
+const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
+const placeholderRegex = /\$(\d+)/g
+const quoteTrimRegex = /^["']|["']$/g

+ 99 - 101
packages/opencode/src/session/retry.ts

@@ -1,125 +1,123 @@
 import type { NamedError } from "@opencode-ai/shared/util/error"
 import { Cause, Clock, Duration, Effect, Schedule } from "effect"
-import { MessageV2 } from "./message-v2"
+import { MessageV2 } from "."
 import { iife } from "@/util/iife"
 
-export namespace SessionRetry {
-  export type Err = ReturnType<NamedError["toObject"]>
+export type Err = ReturnType<NamedError["toObject"]>
 
-  // This exported message is shared with the TUI upsell detector. Matching on a
-  // literal error string kind of sucks, but it is the simplest for now.
-  export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go https://opencode.ai/go"
+// This exported message is shared with the TUI upsell detector. Matching on a
+// literal error string kind of sucks, but it is the simplest for now.
+export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go https://opencode.ai/go"
 
-  export const RETRY_INITIAL_DELAY = 2000
-  export const RETRY_BACKOFF_FACTOR = 2
-  export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
-  export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
+export const RETRY_INITIAL_DELAY = 2000
+export const RETRY_BACKOFF_FACTOR = 2
+export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
+export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
 
-  function cap(ms: number) {
-    return Math.min(ms, RETRY_MAX_DELAY)
-  }
+function cap(ms: number) {
+  return Math.min(ms, RETRY_MAX_DELAY)
+}
 
-  export function delay(attempt: number, error?: MessageV2.APIError) {
-    if (error) {
-      const headers = error.data.responseHeaders
-      if (headers) {
-        const retryAfterMs = headers["retry-after-ms"]
-        if (retryAfterMs) {
-          const parsedMs = Number.parseFloat(retryAfterMs)
-          if (!Number.isNaN(parsedMs)) {
-            return cap(parsedMs)
-          }
+export function delay(attempt: number, error?: MessageV2.APIError) {
+  if (error) {
+    const headers = error.data.responseHeaders
+    if (headers) {
+      const retryAfterMs = headers["retry-after-ms"]
+      if (retryAfterMs) {
+        const parsedMs = Number.parseFloat(retryAfterMs)
+        if (!Number.isNaN(parsedMs)) {
+          return cap(parsedMs)
         }
+      }
 
-        const retryAfter = headers["retry-after"]
-        if (retryAfter) {
-          const parsedSeconds = Number.parseFloat(retryAfter)
-          if (!Number.isNaN(parsedSeconds)) {
-            // convert seconds to milliseconds
-            return cap(Math.ceil(parsedSeconds * 1000))
-          }
-          // Try parsing as HTTP date format
-          const parsed = Date.parse(retryAfter) - Date.now()
-          if (!Number.isNaN(parsed) && parsed > 0) {
-            return cap(Math.ceil(parsed))
-          }
+      const retryAfter = headers["retry-after"]
+      if (retryAfter) {
+        const parsedSeconds = Number.parseFloat(retryAfter)
+        if (!Number.isNaN(parsedSeconds)) {
+          // convert seconds to milliseconds
+          return cap(Math.ceil(parsedSeconds * 1000))
+        }
+        // Try parsing as HTTP date format
+        const parsed = Date.parse(retryAfter) - Date.now()
+        if (!Number.isNaN(parsed) && parsed > 0) {
+          return cap(Math.ceil(parsed))
         }
-
-        return cap(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1))
       }
-    }
 
-    return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS))
+      return cap(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1))
+    }
   }
 
-  export function retryable(error: Err) {
-    // context overflow errors should not be retried
-    if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
-    if (MessageV2.APIError.isInstance(error)) {
-      const status = error.data.statusCode
-      // 5xx errors are transient server failures and should always be retried,
-      // even when the provider SDK doesn't explicitly mark them as retryable.
-      if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined
-      if (error.data.responseBody?.includes("FreeUsageLimitError")) return GO_UPSELL_MESSAGE
-      return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
-    }
+  return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS))
+}
 
-    // Check for rate limit patterns in plain text error messages
-    const msg = error.data?.message
-    if (typeof msg === "string") {
-      const lower = msg.toLowerCase()
-      if (
-        lower.includes("rate increased too quickly") ||
-        lower.includes("rate limit") ||
-        lower.includes("too many requests")
-      ) {
-        return msg
-      }
-    }
+export function retryable(error: Err) {
+  // context overflow errors should not be retried
+  if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
+  if (MessageV2.APIError.isInstance(error)) {
+    const status = error.data.statusCode
+    // 5xx errors are transient server failures and should always be retried,
+    // even when the provider SDK doesn't explicitly mark them as retryable.
+    if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined
+    if (error.data.responseBody?.includes("FreeUsageLimitError")) return GO_UPSELL_MESSAGE
+    return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
+  }
 
-    const json = iife(() => {
-      try {
-        if (typeof error.data?.message === "string") {
-          const parsed = JSON.parse(error.data.message)
-          return parsed
-        }
+  // Check for rate limit patterns in plain text error messages
+  const msg = error.data?.message
+  if (typeof msg === "string") {
+    const lower = msg.toLowerCase()
+    if (
+      lower.includes("rate increased too quickly") ||
+      lower.includes("rate limit") ||
+      lower.includes("too many requests")
+    ) {
+      return msg
+    }
+  }
 
-        return JSON.parse(error.data.message)
-      } catch {
-        return undefined
+  const json = iife(() => {
+    try {
+      if (typeof error.data?.message === "string") {
+        const parsed = JSON.parse(error.data.message)
+        return parsed
       }
-    })
-    if (!json || typeof json !== "object") return undefined
-    const code = typeof json.code === "string" ? json.code : ""
 
-    if (json.type === "error" && json.error?.type === "too_many_requests") {
-      return "Too Many Requests"
-    }
-    if (code.includes("exhausted") || code.includes("unavailable")) {
-      return "Provider is overloaded"
-    }
-    if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) {
-      return "Rate Limited"
+      return JSON.parse(error.data.message)
+    } catch {
+      return undefined
     }
-    return undefined
-  }
+  })
+  if (!json || typeof json !== "object") return undefined
+  const code = typeof json.code === "string" ? json.code : ""
 
-  export function policy(opts: {
-    parse: (error: unknown) => Err
-    set: (input: { attempt: number; message: string; next: number }) => Effect.Effect<void>
-  }) {
-    return Schedule.fromStepWithMetadata(
-      Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
-        const error = opts.parse(meta.input)
-        const message = retryable(error)
-        if (!message) return Cause.done(meta.attempt)
-        return Effect.gen(function* () {
-          const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined)
-          const now = yield* Clock.currentTimeMillis
-          yield* opts.set({ attempt: meta.attempt, message, next: now + wait })
-          return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration]
-        })
-      }),
-    )
+  if (json.type === "error" && json.error?.type === "too_many_requests") {
+    return "Too Many Requests"
+  }
+  if (code.includes("exhausted") || code.includes("unavailable")) {
+    return "Provider is overloaded"
+  }
+  if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) {
+    return "Rate Limited"
   }
+  return undefined
+}
+
+export function policy(opts: {
+  parse: (error: unknown) => Err
+  set: (input: { attempt: number; message: string; next: number }) => Effect.Effect<void>
+}) {
+  return Schedule.fromStepWithMetadata(
+    Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
+      const error = opts.parse(meta.input)
+      const message = retryable(error)
+      if (!message) return Cause.done(meta.attempt)
+      return Effect.gen(function* () {
+        const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined)
+        const now = yield* Clock.currentTimeMillis
+        yield* opts.set({ attempt: meta.attempt, message, next: now + wait })
+        return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration]
+      })
+    }),
+  )
 }

+ 126 - 128
packages/opencode/src/session/revert.ts

@@ -6,156 +6,154 @@ import { Storage } from "@/storage/storage"
 import { SyncEvent } from "../sync"
 import { Log } from "../util/log"
 import { Session } from "."
-import { MessageV2 } from "./message-v2"
+import { MessageV2 } from "."
 import { SessionID, MessageID, PartID } from "./schema"
-import { SessionRunState } from "./run-state"
-import { SessionSummary } from "./summary"
+import { SessionRunState } from "."
+import { SessionSummary } from "."
 
-export namespace SessionRevert {
-  const log = Log.create({ service: "session.revert" })
+const log = Log.create({ service: "session.revert" })
 
-  export const RevertInput = z.object({
-    sessionID: SessionID.zod,
-    messageID: MessageID.zod,
-    partID: PartID.zod.optional(),
-  })
-  export type RevertInput = z.infer<typeof RevertInput>
+export const RevertInput = z.object({
+  sessionID: SessionID.zod,
+  messageID: MessageID.zod,
+  partID: PartID.zod.optional(),
+})
+export type RevertInput = z.infer<typeof RevertInput>
 
-  export interface Interface {
-    readonly revert: (input: RevertInput) => Effect.Effect<Session.Info>
-    readonly unrevert: (input: { sessionID: SessionID }) => Effect.Effect<Session.Info>
-    readonly cleanup: (session: Session.Info) => Effect.Effect<void>
-  }
+export interface Interface {
+  readonly revert: (input: RevertInput) => Effect.Effect<Session.Info>
+  readonly unrevert: (input: { sessionID: SessionID }) => Effect.Effect<Session.Info>
+  readonly cleanup: (session: Session.Info) => Effect.Effect<void>
+}
 
-  export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRevert") {}
+export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRevert") {}
 
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const sessions = yield* Session.Service
-      const snap = yield* Snapshot.Service
-      const storage = yield* Storage.Service
-      const bus = yield* Bus.Service
-      const summary = yield* SessionSummary.Service
-      const state = yield* SessionRunState.Service
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const sessions = yield* Session.Service
+    const snap = yield* Snapshot.Service
+    const storage = yield* Storage.Service
+    const bus = yield* Bus.Service
+    const summary = yield* SessionSummary.Service
+    const state = yield* SessionRunState.Service
 
-      const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
-        yield* state.assertNotBusy(input.sessionID)
-        const all = yield* sessions.messages({ sessionID: input.sessionID })
-        let lastUser: MessageV2.User | undefined
-        const session = yield* sessions.get(input.sessionID)
+    const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
+      yield* state.assertNotBusy(input.sessionID)
+      const all = yield* sessions.messages({ sessionID: input.sessionID })
+      let lastUser: MessageV2.User | undefined
+      const session = yield* sessions.get(input.sessionID)
 
-        let rev: Session.Info["revert"]
-        const patches: Snapshot.Patch[] = []
-        for (const msg of all) {
-          if (msg.info.role === "user") lastUser = msg.info
-          const remaining = []
-          for (const part of msg.parts) {
-            if (rev) {
-              if (part.type === "patch") patches.push(part)
-              continue
-            }
+      let rev: Session.Info["revert"]
+      const patches: Snapshot.Patch[] = []
+      for (const msg of all) {
+        if (msg.info.role === "user") lastUser = msg.info
+        const remaining = []
+        for (const part of msg.parts) {
+          if (rev) {
+            if (part.type === "patch") patches.push(part)
+            continue
+          }
 
-            if (!rev) {
-              if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
-                const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
-                rev = {
-                  messageID: !partID && lastUser ? lastUser.id : msg.info.id,
-                  partID,
-                }
+          if (!rev) {
+            if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
+              const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
+              rev = {
+                messageID: !partID && lastUser ? lastUser.id : msg.info.id,
+                partID,
               }
-              remaining.push(part)
             }
+            remaining.push(part)
           }
         }
+      }
 
-        if (!rev) return session
+      if (!rev) return session
 
-        rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
-        if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
-        yield* snap.revert(patches)
-        if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string)
-        const range = all.filter((msg) => msg.info.id >= rev!.messageID)
-        const diffs = yield* summary.computeDiff({ messages: range })
-        yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
-        yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
-        yield* sessions.setRevert({
-          sessionID: input.sessionID,
-          revert: rev,
-          summary: {
-            additions: diffs.reduce((sum, x) => sum + x.additions, 0),
-            deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
-            files: diffs.length,
-          },
-        })
-        return yield* sessions.get(input.sessionID)
+      rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
+      if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
+      yield* snap.revert(patches)
+      if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string)
+      const range = all.filter((msg) => msg.info.id >= rev!.messageID)
+      const diffs = yield* summary.computeDiff({ messages: range })
+      yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
+      yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
+      yield* sessions.setRevert({
+        sessionID: input.sessionID,
+        revert: rev,
+        summary: {
+          additions: diffs.reduce((sum, x) => sum + x.additions, 0),
+          deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
+          files: diffs.length,
+        },
       })
+      return yield* sessions.get(input.sessionID)
+    })
 
-      const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
-        log.info("unreverting", input)
-        yield* state.assertNotBusy(input.sessionID)
-        const session = yield* sessions.get(input.sessionID)
-        if (!session.revert) return session
-        if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!)
-        yield* sessions.clearRevert(input.sessionID)
-        return yield* sessions.get(input.sessionID)
-      })
+    const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
+      log.info("unreverting", input)
+      yield* state.assertNotBusy(input.sessionID)
+      const session = yield* sessions.get(input.sessionID)
+      if (!session.revert) return session
+      if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!)
+      yield* sessions.clearRevert(input.sessionID)
+      return yield* sessions.get(input.sessionID)
+    })
 
-      const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {
-        if (!session.revert) return
-        const sessionID = session.id
-        const msgs = yield* sessions.messages({ sessionID })
-        const messageID = session.revert.messageID
-        const remove = [] as MessageV2.WithParts[]
-        let target: MessageV2.WithParts | undefined
-        for (const msg of msgs) {
-          if (msg.info.id < messageID) continue
-          if (msg.info.id > messageID) {
-            remove.push(msg)
-            continue
-          }
-          if (session.revert.partID) {
-            target = msg
-            continue
-          }
+    const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {
+      if (!session.revert) return
+      const sessionID = session.id
+      const msgs = yield* sessions.messages({ sessionID })
+      const messageID = session.revert.messageID
+      const remove = [] as MessageV2.WithParts[]
+      let target: MessageV2.WithParts | undefined
+      for (const msg of msgs) {
+        if (msg.info.id < messageID) continue
+        if (msg.info.id > messageID) {
           remove.push(msg)
+          continue
         }
-        for (const msg of remove) {
-          SyncEvent.run(MessageV2.Event.Removed, {
-            sessionID,
-            messageID: msg.info.id,
-          })
+        if (session.revert.partID) {
+          target = msg
+          continue
         }
-        if (session.revert.partID && target) {
-          const partID = session.revert.partID
-          const idx = target.parts.findIndex((part) => part.id === partID)
-          if (idx >= 0) {
-            const removeParts = target.parts.slice(idx)
-            target.parts = target.parts.slice(0, idx)
-            for (const part of removeParts) {
-              SyncEvent.run(MessageV2.Event.PartRemoved, {
-                sessionID,
-                messageID: target.info.id,
-                partID: part.id,
-              })
-            }
+        remove.push(msg)
+      }
+      for (const msg of remove) {
+        SyncEvent.run(MessageV2.Event.Removed, {
+          sessionID,
+          messageID: msg.info.id,
+        })
+      }
+      if (session.revert.partID && target) {
+        const partID = session.revert.partID
+        const idx = target.parts.findIndex((part) => part.id === partID)
+        if (idx >= 0) {
+          const removeParts = target.parts.slice(idx)
+          target.parts = target.parts.slice(0, idx)
+          for (const part of removeParts) {
+            SyncEvent.run(MessageV2.Event.PartRemoved, {
+              sessionID,
+              messageID: target.info.id,
+              partID: part.id,
+            })
           }
         }
-        yield* sessions.clearRevert(sessionID)
-      })
+      }
+      yield* sessions.clearRevert(sessionID)
+    })
 
-      return Service.of({ revert, unrevert, cleanup })
-    }),
-  )
+    return Service.of({ revert, unrevert, cleanup })
+  }),
+)
 
-  export const defaultLayer = Layer.suspend(() =>
-    layer.pipe(
-      Layer.provide(SessionRunState.defaultLayer),
-      Layer.provide(Session.defaultLayer),
-      Layer.provide(Snapshot.defaultLayer),
-      Layer.provide(Storage.defaultLayer),
-      Layer.provide(Bus.layer),
-      Layer.provide(SessionSummary.defaultLayer),
-    ),
-  )
-}
+export const defaultLayer = Layer.suspend(() =>
+  layer.pipe(
+    Layer.provide(SessionRunState.defaultLayer),
+    Layer.provide(Session.defaultLayer),
+    Layer.provide(Snapshot.defaultLayer),
+    Layer.provide(Storage.defaultLayer),
+    Layer.provide(Bus.layer),
+    Layer.provide(SessionSummary.defaultLayer),
+  ),
+)

+ 89 - 91
packages/opencode/src/session/run-state.ts

@@ -2,107 +2,105 @@ import { InstanceState } from "@/effect"
 import { Runner } from "@/effect/runner"
 import { Effect, Layer, Scope, Context } from "effect"
 import { Session } from "."
-import { MessageV2 } from "./message-v2"
+import { MessageV2 } from "."
 import { SessionID } from "./schema"
-import { SessionStatus } from "./status"
+import { SessionStatus } from "."
 
-export namespace SessionRunState {
-  export interface Interface {
-    readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect<void>
-    readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
-    readonly ensureRunning: (
-      sessionID: SessionID,
-      onInterrupt: Effect.Effect<MessageV2.WithParts>,
-      work: Effect.Effect<MessageV2.WithParts>,
-    ) => Effect.Effect<MessageV2.WithParts>
-    readonly startShell: (
-      sessionID: SessionID,
-      onInterrupt: Effect.Effect<MessageV2.WithParts>,
-      work: Effect.Effect<MessageV2.WithParts>,
-    ) => Effect.Effect<MessageV2.WithParts>
-  }
-
-  export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRunState") {}
+export interface Interface {
+  readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect<void>
+  readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
+  readonly ensureRunning: (
+    sessionID: SessionID,
+    onInterrupt: Effect.Effect<MessageV2.WithParts>,
+    work: Effect.Effect<MessageV2.WithParts>,
+  ) => Effect.Effect<MessageV2.WithParts>
+  readonly startShell: (
+    sessionID: SessionID,
+    onInterrupt: Effect.Effect<MessageV2.WithParts>,
+    work: Effect.Effect<MessageV2.WithParts>,
+  ) => Effect.Effect<MessageV2.WithParts>
+}
 
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const status = yield* SessionStatus.Service
+export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRunState") {}
 
-      const state = yield* InstanceState.make(
-        Effect.fn("SessionRunState.state")(function* () {
-          const scope = yield* Scope.Scope
-          const runners = new Map<SessionID, Runner<MessageV2.WithParts>>()
-          yield* Effect.addFinalizer(
-            Effect.fnUntraced(function* () {
-              yield* Effect.forEach(runners.values(), (runner) => runner.cancel, {
-                concurrency: "unbounded",
-                discard: true,
-              })
-              runners.clear()
-            }),
-          )
-          return { runners, scope }
-        }),
-      )
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const status = yield* SessionStatus.Service
 
-      const runner = Effect.fn("SessionRunState.runner")(function* (
-        sessionID: SessionID,
-        onInterrupt: Effect.Effect<MessageV2.WithParts>,
-      ) {
-        const data = yield* InstanceState.get(state)
-        const existing = data.runners.get(sessionID)
-        if (existing) return existing
-        const next = Runner.make<MessageV2.WithParts>(data.scope, {
-          onIdle: Effect.gen(function* () {
-            data.runners.delete(sessionID)
-            yield* status.set(sessionID, { type: "idle" })
+    const state = yield* InstanceState.make(
+      Effect.fn("SessionRunState.state")(function* () {
+        const scope = yield* Scope.Scope
+        const runners = new Map<SessionID, Runner<MessageV2.WithParts>>()
+        yield* Effect.addFinalizer(
+          Effect.fnUntraced(function* () {
+            yield* Effect.forEach(runners.values(), (runner) => runner.cancel, {
+              concurrency: "unbounded",
+              discard: true,
+            })
+            runners.clear()
           }),
-          onBusy: status.set(sessionID, { type: "busy" }),
-          onInterrupt,
-          busy: () => {
-            throw new Session.BusyError(sessionID)
-          },
-        })
-        data.runners.set(sessionID, next)
-        return next
-      })
+        )
+        return { runners, scope }
+      }),
+    )
 
-      const assertNotBusy = Effect.fn("SessionRunState.assertNotBusy")(function* (sessionID: SessionID) {
-        const data = yield* InstanceState.get(state)
-        const existing = data.runners.get(sessionID)
-        if (existing?.busy) throw new Session.BusyError(sessionID)
-      })
-
-      const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) {
-        const data = yield* InstanceState.get(state)
-        const existing = data.runners.get(sessionID)
-        if (!existing || !existing.busy) {
+    const runner = Effect.fn("SessionRunState.runner")(function* (
+      sessionID: SessionID,
+      onInterrupt: Effect.Effect<MessageV2.WithParts>,
+    ) {
+      const data = yield* InstanceState.get(state)
+      const existing = data.runners.get(sessionID)
+      if (existing) return existing
+      const next = Runner.make<MessageV2.WithParts>(data.scope, {
+        onIdle: Effect.gen(function* () {
+          data.runners.delete(sessionID)
           yield* status.set(sessionID, { type: "idle" })
-          return
-        }
-        yield* existing.cancel
+        }),
+        onBusy: status.set(sessionID, { type: "busy" }),
+        onInterrupt,
+        busy: () => {
+          throw new Session.BusyError(sessionID)
+        },
       })
+      data.runners.set(sessionID, next)
+      return next
+    })
 
-      const ensureRunning = Effect.fn("SessionRunState.ensureRunning")(function* (
-        sessionID: SessionID,
-        onInterrupt: Effect.Effect<MessageV2.WithParts>,
-        work: Effect.Effect<MessageV2.WithParts>,
-      ) {
-        return yield* (yield* runner(sessionID, onInterrupt)).ensureRunning(work)
-      })
+    const assertNotBusy = Effect.fn("SessionRunState.assertNotBusy")(function* (sessionID: SessionID) {
+      const data = yield* InstanceState.get(state)
+      const existing = data.runners.get(sessionID)
+      if (existing?.busy) throw new Session.BusyError(sessionID)
+    })
 
-      const startShell = Effect.fn("SessionRunState.startShell")(function* (
-        sessionID: SessionID,
-        onInterrupt: Effect.Effect<MessageV2.WithParts>,
-        work: Effect.Effect<MessageV2.WithParts>,
-      ) {
-        return yield* (yield* runner(sessionID, onInterrupt)).startShell(work)
-      })
+    const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) {
+      const data = yield* InstanceState.get(state)
+      const existing = data.runners.get(sessionID)
+      if (!existing || !existing.busy) {
+        yield* status.set(sessionID, { type: "idle" })
+        return
+      }
+      yield* existing.cancel
+    })
 
-      return Service.of({ assertNotBusy, cancel, ensureRunning, startShell })
-    }),
-  )
+    const ensureRunning = Effect.fn("SessionRunState.ensureRunning")(function* (
+      sessionID: SessionID,
+      onInterrupt: Effect.Effect<MessageV2.WithParts>,
+      work: Effect.Effect<MessageV2.WithParts>,
+    ) {
+      return yield* (yield* runner(sessionID, onInterrupt)).ensureRunning(work)
+    })
 
-  export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))
-}
+    const startShell = Effect.fn("SessionRunState.startShell")(function* (
+      sessionID: SessionID,
+      onInterrupt: Effect.Effect<MessageV2.WithParts>,
+      work: Effect.Effect<MessageV2.WithParts>,
+    ) {
+      return yield* (yield* runner(sessionID, onInterrupt)).startShell(work)
+    })
+
+    return Service.of({ assertNotBusy, cancel, ensureRunning, startShell })
+  }),
+)
+
+export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))

+ 1 - 1
packages/opencode/src/session/session.sql.ts

@@ -1,6 +1,6 @@
 import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"
 import { ProjectTable } from "../project/project.sql"
-import type { MessageV2 } from "./message-v2"
+import type { MessageV2 } from "."
 import type { SessionEntry } from "../v2/session-entry"
 import type { Snapshot } from "../snapshot"
 import type { Permission } from "../permission"

+ 1 - 1
packages/opencode/src/session/session.ts

@@ -16,7 +16,7 @@ import { ProjectTable } from "../project/project.sql"
 import { Storage } from "@/storage/storage"
 import { Log } from "../util/log"
 import { updateSchema } from "../util/update-schema"
-import { MessageV2 } from "./message-v2"
+import { MessageV2 } from "."
 import { Instance } from "../project/instance"
 import { InstanceState } from "@/effect"
 import { Snapshot } from "@/snapshot"

+ 69 - 71
packages/opencode/src/session/status.ts

@@ -5,84 +5,82 @@ import { SessionID } from "./schema"
 import { Effect, Layer, Context } from "effect"
 import z from "zod"
 
-export namespace SessionStatus {
-  export const Info = z
-    .union([
-      z.object({
-        type: z.literal("idle"),
-      }),
-      z.object({
-        type: z.literal("retry"),
-        attempt: z.number(),
-        message: z.string(),
-        next: z.number(),
-      }),
-      z.object({
-        type: z.literal("busy"),
-      }),
-    ])
-    .meta({
-      ref: "SessionStatus",
-    })
-  export type Info = z.infer<typeof Info>
+export const Info = z
+  .union([
+    z.object({
+      type: z.literal("idle"),
+    }),
+    z.object({
+      type: z.literal("retry"),
+      attempt: z.number(),
+      message: z.string(),
+      next: z.number(),
+    }),
+    z.object({
+      type: z.literal("busy"),
+    }),
+  ])
+  .meta({
+    ref: "SessionStatus",
+  })
+export type Info = z.infer<typeof Info>
 
-  export const Event = {
-    Status: BusEvent.define(
-      "session.status",
-      z.object({
-        sessionID: SessionID.zod,
-        status: Info,
-      }),
-    ),
-    // deprecated
-    Idle: BusEvent.define(
-      "session.idle",
-      z.object({
-        sessionID: SessionID.zod,
-      }),
-    ),
-  }
+export const Event = {
+  Status: BusEvent.define(
+    "session.status",
+    z.object({
+      sessionID: SessionID.zod,
+      status: Info,
+    }),
+  ),
+  // deprecated
+  Idle: BusEvent.define(
+    "session.idle",
+    z.object({
+      sessionID: SessionID.zod,
+    }),
+  ),
+}
 
-  export interface Interface {
-    readonly get: (sessionID: SessionID) => Effect.Effect<Info>
-    readonly list: () => Effect.Effect<Map<SessionID, Info>>
-    readonly set: (sessionID: SessionID, status: Info) => Effect.Effect<void>
-  }
+export interface Interface {
+  readonly get: (sessionID: SessionID) => Effect.Effect<Info>
+  readonly list: () => Effect.Effect<Map<SessionID, Info>>
+  readonly set: (sessionID: SessionID, status: Info) => Effect.Effect<void>
+}
 
-  export class Service extends Context.Service<Service, Interface>()("@opencode/SessionStatus") {}
+export class Service extends Context.Service<Service, Interface>()("@opencode/SessionStatus") {}
 
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const bus = yield* Bus.Service
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const bus = yield* Bus.Service
 
-      const state = yield* InstanceState.make(
-        Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map<SessionID, Info>())),
-      )
+    const state = yield* InstanceState.make(
+      Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map<SessionID, Info>())),
+    )
 
-      const get = Effect.fn("SessionStatus.get")(function* (sessionID: SessionID) {
-        const data = yield* InstanceState.get(state)
-        return data.get(sessionID) ?? { type: "idle" as const }
-      })
+    const get = Effect.fn("SessionStatus.get")(function* (sessionID: SessionID) {
+      const data = yield* InstanceState.get(state)
+      return data.get(sessionID) ?? { type: "idle" as const }
+    })
 
-      const list = Effect.fn("SessionStatus.list")(function* () {
-        return new Map(yield* InstanceState.get(state))
-      })
+    const list = Effect.fn("SessionStatus.list")(function* () {
+      return new Map(yield* InstanceState.get(state))
+    })
 
-      const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) {
-        const data = yield* InstanceState.get(state)
-        yield* bus.publish(Event.Status, { sessionID, status })
-        if (status.type === "idle") {
-          yield* bus.publish(Event.Idle, { sessionID })
-          data.delete(sessionID)
-          return
-        }
-        data.set(sessionID, status)
-      })
+    const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) {
+      const data = yield* InstanceState.get(state)
+      yield* bus.publish(Event.Status, { sessionID, status })
+      if (status.type === "idle") {
+        yield* bus.publish(Event.Idle, { sessionID })
+        data.delete(sessionID)
+        return
+      }
+      data.set(sessionID, status)
+    })
 
-      return Service.of({ get, list, set })
-    }),
-  )
+    return Service.of({ get, list, set })
+  }),
+)
 
-  export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
-}
+export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))

+ 141 - 143
packages/opencode/src/session/summary.ts

@@ -4,162 +4,160 @@ import { Bus } from "@/bus"
 import { Snapshot } from "@/snapshot"
 import { Storage } from "@/storage/storage"
 import { Session } from "."
-import { MessageV2 } from "./message-v2"
+import { MessageV2 } from "."
 import { SessionID, MessageID } from "./schema"
 
-export namespace SessionSummary {
-  function unquoteGitPath(input: string) {
-    if (!input.startsWith('"')) return input
-    if (!input.endsWith('"')) return input
-    const body = input.slice(1, -1)
-    const bytes: number[] = []
-
-    for (let i = 0; i < body.length; i++) {
-      const char = body[i]!
-      if (char !== "\\") {
-        bytes.push(char.charCodeAt(0))
-        continue
-      }
+function unquoteGitPath(input: string) {
+  if (!input.startsWith('"')) return input
+  if (!input.endsWith('"')) return input
+  const body = input.slice(1, -1)
+  const bytes: number[] = []
+
+  for (let i = 0; i < body.length; i++) {
+    const char = body[i]!
+    if (char !== "\\") {
+      bytes.push(char.charCodeAt(0))
+      continue
+    }
 
-      const next = body[i + 1]
-      if (!next) {
-        bytes.push("\\".charCodeAt(0))
-        continue
-      }
+    const next = body[i + 1]
+    if (!next) {
+      bytes.push("\\".charCodeAt(0))
+      continue
+    }
 
-      if (next >= "0" && next <= "7") {
-        const chunk = body.slice(i + 1, i + 4)
-        const match = chunk.match(/^[0-7]{1,3}/)
-        if (!match) {
-          bytes.push(next.charCodeAt(0))
-          i++
-          continue
-        }
-        bytes.push(parseInt(match[0], 8))
-        i += match[0].length
+    if (next >= "0" && next <= "7") {
+      const chunk = body.slice(i + 1, i + 4)
+      const match = chunk.match(/^[0-7]{1,3}/)
+      if (!match) {
+        bytes.push(next.charCodeAt(0))
+        i++
         continue
       }
-
-      const escaped =
-        next === "n"
-          ? "\n"
-          : next === "r"
-            ? "\r"
-            : next === "t"
-              ? "\t"
-              : next === "b"
-                ? "\b"
-                : next === "f"
-                  ? "\f"
-                  : next === "v"
-                    ? "\v"
-                    : next === "\\" || next === '"'
-                      ? next
-                      : undefined
-
-      bytes.push((escaped ?? next).charCodeAt(0))
-      i++
+      bytes.push(parseInt(match[0], 8))
+      i += match[0].length
+      continue
     }
 
-    return Buffer.from(bytes).toString()
+    const escaped =
+      next === "n"
+        ? "\n"
+        : next === "r"
+          ? "\r"
+          : next === "t"
+            ? "\t"
+            : next === "b"
+              ? "\b"
+              : next === "f"
+                ? "\f"
+                : next === "v"
+                  ? "\v"
+                  : next === "\\" || next === '"'
+                    ? next
+                    : undefined
+
+    bytes.push((escaped ?? next).charCodeAt(0))
+    i++
   }
 
-  export interface Interface {
-    readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<void>
-    readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Snapshot.FileDiff[]>
-    readonly computeDiff: (input: { messages: MessageV2.WithParts[] }) => Effect.Effect<Snapshot.FileDiff[]>
-  }
+  return Buffer.from(bytes).toString()
+}
 
-  export class Service extends Context.Service<Service, Interface>()("@opencode/SessionSummary") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const sessions = yield* Session.Service
-      const snapshot = yield* Snapshot.Service
-      const storage = yield* Storage.Service
-      const bus = yield* Bus.Service
-
-      const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: {
-        messages: MessageV2.WithParts[]
-      }) {
-        let from: string | undefined
-        let to: string | undefined
-        for (const item of input.messages) {
-          if (!from) {
-            for (const part of item.parts) {
-              if (part.type === "step-start" && part.snapshot) {
-                from = part.snapshot
-                break
-              }
-            }
-          }
+export interface Interface {
+  readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<void>
+  readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Snapshot.FileDiff[]>
+  readonly computeDiff: (input: { messages: MessageV2.WithParts[] }) => Effect.Effect<Snapshot.FileDiff[]>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/SessionSummary") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const sessions = yield* Session.Service
+    const snapshot = yield* Snapshot.Service
+    const storage = yield* Storage.Service
+    const bus = yield* Bus.Service
+
+    const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: {
+      messages: MessageV2.WithParts[]
+    }) {
+      let from: string | undefined
+      let to: string | undefined
+      for (const item of input.messages) {
+        if (!from) {
           for (const part of item.parts) {
-            if (part.type === "step-finish" && part.snapshot) to = part.snapshot
+            if (part.type === "step-start" && part.snapshot) {
+              from = part.snapshot
+              break
+            }
           }
         }
-        if (from && to) return yield* snapshot.diffFull(from, to)
-        return []
-      })
-
-      const summarize = Effect.fn("SessionSummary.summarize")(function* (input: {
-        sessionID: SessionID
-        messageID: MessageID
-      }) {
-        const all = yield* sessions.messages({ sessionID: input.sessionID })
-        if (!all.length) return
-
-        const diffs = yield* computeDiff({ messages: all })
-        yield* sessions.setSummary({
-          sessionID: input.sessionID,
-          summary: {
-            additions: diffs.reduce((sum, x) => sum + x.additions, 0),
-            deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
-            files: diffs.length,
-          },
-        })
-        yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
-        yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
-
-        const messages = all.filter(
-          (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
-        )
-        const target = messages.find((m) => m.info.id === input.messageID)
-        if (!target || target.info.role !== "user") return
-        const msgDiffs = yield* computeDiff({ messages })
-        target.info.summary = { ...target.info.summary, diffs: msgDiffs }
-        yield* sessions.updateMessage(target.info)
+        for (const part of item.parts) {
+          if (part.type === "step-finish" && part.snapshot) to = part.snapshot
+        }
+      }
+      if (from && to) return yield* snapshot.diffFull(from, to)
+      return []
+    })
+
+    const summarize = Effect.fn("SessionSummary.summarize")(function* (input: {
+      sessionID: SessionID
+      messageID: MessageID
+    }) {
+      const all = yield* sessions.messages({ sessionID: input.sessionID })
+      if (!all.length) return
+
+      const diffs = yield* computeDiff({ messages: all })
+      yield* sessions.setSummary({
+        sessionID: input.sessionID,
+        summary: {
+          additions: diffs.reduce((sum, x) => sum + x.additions, 0),
+          deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
+          files: diffs.length,
+        },
       })
-
-      const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
-        const diffs = yield* storage
-          .read<Snapshot.FileDiff[]>(["session_diff", input.sessionID])
-          .pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[])))
-        const next = diffs.map((item) => {
-          const file = unquoteGitPath(item.file)
-          if (file === item.file) return item
-          return { ...item, file }
-        })
-        const changed = next.some((item, i) => item.file !== diffs[i]?.file)
-        if (changed) yield* storage.write(["session_diff", input.sessionID], next).pipe(Effect.ignore)
-        return next
+      yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
+      yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
+
+      const messages = all.filter(
+        (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
+      )
+      const target = messages.find((m) => m.info.id === input.messageID)
+      if (!target || target.info.role !== "user") return
+      const msgDiffs = yield* computeDiff({ messages })
+      target.info.summary = { ...target.info.summary, diffs: msgDiffs }
+      yield* sessions.updateMessage(target.info)
+    })
+
+    const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
+      const diffs = yield* storage
+        .read<Snapshot.FileDiff[]>(["session_diff", input.sessionID])
+        .pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[])))
+      const next = diffs.map((item) => {
+        const file = unquoteGitPath(item.file)
+        if (file === item.file) return item
+        return { ...item, file }
       })
-
-      return Service.of({ summarize, diff, computeDiff })
-    }),
-  )
-
-  export const defaultLayer = Layer.suspend(() =>
-    layer.pipe(
-      Layer.provide(Session.defaultLayer),
-      Layer.provide(Snapshot.defaultLayer),
-      Layer.provide(Storage.defaultLayer),
-      Layer.provide(Bus.layer),
-    ),
-  )
-
-  export const DiffInput = z.object({
-    sessionID: SessionID.zod,
-    messageID: MessageID.zod.optional(),
-  })
-}
+      const changed = next.some((item, i) => item.file !== diffs[i]?.file)
+      if (changed) yield* storage.write(["session_diff", input.sessionID], next).pipe(Effect.ignore)
+      return next
+    })
+
+    return Service.of({ summarize, diff, computeDiff })
+  }),
+)
+
+export const defaultLayer = Layer.suspend(() =>
+  layer.pipe(
+    Layer.provide(Session.defaultLayer),
+    Layer.provide(Snapshot.defaultLayer),
+    Layer.provide(Storage.defaultLayer),
+    Layer.provide(Bus.layer),
+  ),
+)
+
+export const DiffInput = z.object({
+  sessionID: SessionID.zod,
+  messageID: MessageID.zod.optional(),
+})

+ 54 - 56
packages/opencode/src/session/system.ts

@@ -16,69 +16,67 @@ import type { Agent } from "@/agent/agent"
 import { Permission } from "@/permission"
 import { Skill } from "@/skill"
 
-export namespace SystemPrompt {
-  export function provider(model: Provider.Model) {
-    if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3"))
-      return [PROMPT_BEAST]
-    if (model.api.id.includes("gpt")) {
-      if (model.api.id.includes("codex")) {
-        return [PROMPT_CODEX]
-      }
-      return [PROMPT_GPT]
+export function provider(model: Provider.Model) {
+  if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3"))
+    return [PROMPT_BEAST]
+  if (model.api.id.includes("gpt")) {
+    if (model.api.id.includes("codex")) {
+      return [PROMPT_CODEX]
     }
-    if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI]
-    if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
-    if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY]
-    if (model.api.id.toLowerCase().includes("kimi")) return [PROMPT_KIMI]
-    return [PROMPT_DEFAULT]
+    return [PROMPT_GPT]
   }
+  if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI]
+  if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
+  if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY]
+  if (model.api.id.toLowerCase().includes("kimi")) return [PROMPT_KIMI]
+  return [PROMPT_DEFAULT]
+}
 
-  export interface Interface {
-    readonly environment: (model: Provider.Model) => string[]
-    readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined>
-  }
+export interface Interface {
+  readonly environment: (model: Provider.Model) => string[]
+  readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined>
+}
 
-  export class Service extends Context.Service<Service, Interface>()("@opencode/SystemPrompt") {}
+export class Service extends Context.Service<Service, Interface>()("@opencode/SystemPrompt") {}
 
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const skill = yield* Skill.Service
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const skill = yield* Skill.Service
 
-      return Service.of({
-        environment(model) {
-          const project = Instance.project
-          return [
-            [
-              `You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
-              `Here is some useful information about the environment you are running in:`,
-              `<env>`,
-              `  Working directory: ${Instance.directory}`,
-              `  Workspace root folder: ${Instance.worktree}`,
-              `  Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
-              `  Platform: ${process.platform}`,
-              `  Today's date: ${new Date().toDateString()}`,
-              `</env>`,
-            ].join("\n"),
-          ]
-        },
+    return Service.of({
+      environment(model) {
+        const project = Instance.project
+        return [
+          [
+            `You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
+            `Here is some useful information about the environment you are running in:`,
+            `<env>`,
+            `  Working directory: ${Instance.directory}`,
+            `  Workspace root folder: ${Instance.worktree}`,
+            `  Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
+            `  Platform: ${process.platform}`,
+            `  Today's date: ${new Date().toDateString()}`,
+            `</env>`,
+          ].join("\n"),
+        ]
+      },
 
-        skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
-          if (Permission.disabled(["skill"], agent.permission).has("skill")) return
+      skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
+        if (Permission.disabled(["skill"], agent.permission).has("skill")) return
 
-          const list = yield* skill.available(agent)
+        const list = yield* skill.available(agent)
 
-          return [
-            "Skills provide specialized instructions and workflows for specific tasks.",
-            "Use the skill tool to load a skill when a task matches its description.",
-            // the agents seem to ingest the information about skills a bit better if we present a more verbose
-            // version of them here and a less verbose version in tool description, rather than vice versa.
-            Skill.fmt(list, { verbose: true }),
-          ].join("\n")
-        }),
-      })
-    }),
-  )
+        return [
+          "Skills provide specialized instructions and workflows for specific tasks.",
+          "Use the skill tool to load a skill when a task matches its description.",
+          // the agents seem to ingest the information about skills a bit better if we present a more verbose
+          // version of them here and a less verbose version in tool description, rather than vice versa.
+          Skill.fmt(list, { verbose: true }),
+        ].join("\n")
+      }),
+    })
+  }),
+)
 
-  export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer))
-}
+export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer))

+ 67 - 69
packages/opencode/src/session/todo.ts

@@ -6,80 +6,78 @@ import z from "zod"
 import { Database, eq, asc } from "../storage/db"
 import { TodoTable } from "./session.sql"
 
-export namespace Todo {
-  export const Info = z
-    .object({
-      content: z.string().describe("Brief description of the task"),
-      status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
-      priority: z.string().describe("Priority level of the task: high, medium, low"),
-    })
-    .meta({ ref: "Todo" })
-  export type Info = z.infer<typeof Info>
+export const Info = z
+  .object({
+    content: z.string().describe("Brief description of the task"),
+    status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
+    priority: z.string().describe("Priority level of the task: high, medium, low"),
+  })
+  .meta({ ref: "Todo" })
+export type Info = z.infer<typeof Info>
 
-  export const Event = {
-    Updated: BusEvent.define(
-      "todo.updated",
-      z.object({
-        sessionID: SessionID.zod,
-        todos: z.array(Info),
-      }),
-    ),
-  }
+export const Event = {
+  Updated: BusEvent.define(
+    "todo.updated",
+    z.object({
+      sessionID: SessionID.zod,
+      todos: z.array(Info),
+    }),
+  ),
+}
 
-  export interface Interface {
-    readonly update: (input: { sessionID: SessionID; todos: Info[] }) => Effect.Effect<void>
-    readonly get: (sessionID: SessionID) => Effect.Effect<Info[]>
-  }
+export interface Interface {
+  readonly update: (input: { sessionID: SessionID; todos: Info[] }) => Effect.Effect<void>
+  readonly get: (sessionID: SessionID) => Effect.Effect<Info[]>
+}
 
-  export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTodo") {}
+export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTodo") {}
 
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const bus = yield* Bus.Service
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const bus = yield* Bus.Service
 
-      const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: Info[] }) {
-        yield* Effect.sync(() =>
-          Database.transaction((db) => {
-            db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
-            if (input.todos.length === 0) return
-            db.insert(TodoTable)
-              .values(
-                input.todos.map((todo, position) => ({
-                  session_id: input.sessionID,
-                  content: todo.content,
-                  status: todo.status,
-                  priority: todo.priority,
-                  position,
-                })),
-              )
-              .run()
-          }),
-        )
-        yield* bus.publish(Event.Updated, input)
-      })
+    const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: Info[] }) {
+      yield* Effect.sync(() =>
+        Database.transaction((db) => {
+          db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
+          if (input.todos.length === 0) return
+          db.insert(TodoTable)
+            .values(
+              input.todos.map((todo, position) => ({
+                session_id: input.sessionID,
+                content: todo.content,
+                status: todo.status,
+                priority: todo.priority,
+                position,
+              })),
+            )
+            .run()
+        }),
+      )
+      yield* bus.publish(Event.Updated, input)
+    })
 
-      const get = Effect.fn("Todo.get")(function* (sessionID: SessionID) {
-        const rows = yield* Effect.sync(() =>
-          Database.use((db) =>
-            db
-              .select()
-              .from(TodoTable)
-              .where(eq(TodoTable.session_id, sessionID))
-              .orderBy(asc(TodoTable.position))
-              .all(),
-          ),
-        )
-        return rows.map((row) => ({
-          content: row.content,
-          status: row.status,
-          priority: row.priority,
-        }))
-      })
+    const get = Effect.fn("Todo.get")(function* (sessionID: SessionID) {
+      const rows = yield* Effect.sync(() =>
+        Database.use((db) =>
+          db
+            .select()
+            .from(TodoTable)
+            .where(eq(TodoTable.session_id, sessionID))
+            .orderBy(asc(TodoTable.position))
+            .all(),
+        ),
+      )
+      return rows.map((row) => ({
+        content: row.content,
+        status: row.status,
+        priority: row.priority,
+      }))
+    })
 
-      return Service.of({ update, get })
-    }),
-  )
+    return Service.of({ update, get })
+  }),
+)
 
-  export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
-}
+export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))

+ 1 - 1
packages/opencode/src/share/share-next.ts

@@ -7,7 +7,7 @@ import { InstanceState } from "@/effect"
 import { Provider } from "@/provider"
 import { ModelID, ProviderID } from "@/provider/schema"
 import { Session } from "@/session"
-import { MessageV2 } from "@/session/message-v2"
+import { MessageV2 } from "@/session"
 import type { SessionID } from "@/session/schema"
 import { Database, eq } from "@/storage/db"
 import { Config } from "@/config"

+ 1 - 1
packages/opencode/src/tool/plan.ts

@@ -4,7 +4,7 @@ import { Effect } from "effect"
 import { Tool } from "./tool"
 import { Question } from "../question"
 import { Session } from "../session"
-import { MessageV2 } from "../session/message-v2"
+import { MessageV2 } from "../session"
 import { Provider } from "../provider"
 import { Instance } from "../project/instance"
 import { type SessionID, MessageID, PartID } from "../session/schema"

+ 1 - 1
packages/opencode/src/tool/read.ts

@@ -11,7 +11,7 @@ import { FileTime } from "../file/time"
 import DESCRIPTION from "./read.txt"
 import { Instance } from "../project/instance"
 import { assertExternalDirectoryEffect } from "./external-directory"
-import { Instruction } from "../session/instruction"
+import { Instruction } from "../session"
 
 const DEFAULT_READ_LIMIT = 2000
 const MAX_LINE_LENGTH = 2000

+ 2 - 2
packages/opencode/src/tool/registry.ts

@@ -37,10 +37,10 @@ import { Ripgrep } from "../file/ripgrep"
 import { Format } from "../format"
 import { InstanceState } from "@/effect"
 import { Question } from "../question"
-import { Todo } from "../session/todo"
+import { Todo } from "../session"
 import { LSP } from "../lsp"
 import { FileTime } from "../file/time"
-import { Instruction } from "../session/instruction"
+import { Instruction } from "../session"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { Bus } from "../bus"
 import { Agent } from "../agent/agent"

+ 2 - 2
packages/opencode/src/tool/task.ts

@@ -3,9 +3,9 @@ import DESCRIPTION from "./task.txt"
 import z from "zod"
 import { Session } from "../session"
 import { SessionID, MessageID } from "../session/schema"
-import { MessageV2 } from "../session/message-v2"
+import { MessageV2 } from "../session"
 import { Agent } from "../agent/agent"
-import type { SessionPrompt } from "../session/prompt"
+import type { SessionPrompt } from "../session"
 import { Config } from "../config"
 import { Effect } from "effect"
 

+ 1 - 1
packages/opencode/src/tool/todo.ts

@@ -2,7 +2,7 @@ import z from "zod"
 import { Effect } from "effect"
 import { Tool } from "./tool"
 import DESCRIPTION_WRITE from "./todowrite.txt"
-import { Todo } from "../session/todo"
+import { Todo } from "../session"
 
 const parameters = z.object({
   todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"),

+ 1 - 1
packages/opencode/src/tool/tool.ts

@@ -1,6 +1,6 @@
 import z from "zod"
 import { Effect } from "effect"
-import type { MessageV2 } from "../session/message-v2"
+import type { MessageV2 } from "../session"
 import type { Permission } from "../permission"
 import type { SessionID, MessageID } from "../session/schema"
 import { Truncate } from "./truncate"

+ 1 - 1
packages/opencode/test/cli/github-action.test.ts

@@ -1,6 +1,6 @@
 import { test, expect, describe } from "bun:test"
 import { extractResponseText, formatPromptTooLargeError } from "../../src/cli/cmd/github"
-import type { MessageV2 } from "../../src/session/message-v2"
+import type { MessageV2 } from "../../src/session"
 import { SessionID, MessageID, PartID } from "../../src/session/schema"
 
 // Helper to create minimal valid parts

+ 1 - 1
packages/opencode/test/server/session-messages.test.ts

@@ -3,7 +3,7 @@ import { Effect } from "effect"
 import { Instance } from "../../src/project/instance"
 import { Server } from "../../src/server/server"
 import { Session as SessionNs } from "../../src/session"
-import { MessageV2 } from "../../src/session/message-v2"
+import { MessageV2 } from "../../src/session"
 import { MessageID, PartID, type SessionID } from "../../src/session/schema"
 import { Log } from "../../src/util/log"
 import { tmpdir } from "../fixture/fixture"

+ 6 - 6
packages/opencode/test/session/compaction.test.ts

@@ -6,8 +6,8 @@ import z from "zod"
 import { Bus } from "../../src/bus"
 import { Config } from "../../src/config"
 import { Agent } from "../../src/agent/agent"
-import { LLM } from "../../src/session/llm"
-import { SessionCompaction } from "../../src/session/compaction"
+import { LLM } from "../../src/session"
+import { SessionCompaction } from "../../src/session"
 import { Token } from "../../src/util/token"
 import { Instance } from "../../src/project/instance"
 import { Log } from "../../src/util/log"
@@ -15,13 +15,13 @@ import { Permission } from "../../src/permission"
 import { Plugin } from "../../src/plugin"
 import { provideTmpdirInstance, tmpdir } from "../fixture/fixture"
 import { Session as SessionNs } from "../../src/session"
-import { MessageV2 } from "../../src/session/message-v2"
+import { MessageV2 } from "../../src/session"
 import { MessageID, PartID, SessionID } from "../../src/session/schema"
-import { SessionStatus } from "../../src/session/status"
-import { SessionSummary } from "../../src/session/summary"
+import { SessionStatus } from "../../src/session"
+import { SessionSummary } from "../../src/session"
 import { ModelID, ProviderID } from "../../src/provider/schema"
 import type { Provider } from "../../src/provider"
-import * as SessionProcessorModule from "../../src/session/processor"
+import * as SessionProcessorModule from "../../src/session"
 import { Snapshot } from "../../src/snapshot"
 import { ProviderTest } from "../fake/provider"
 import { testEffect } from "../lib/effect"

+ 2 - 2
packages/opencode/test/session/instruction.test.ts

@@ -2,8 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
 import path from "path"
 import { Effect } from "effect"
 import { ModelID, ProviderID } from "../../src/provider/schema"
-import { Instruction } from "../../src/session/instruction"
-import type { MessageV2 } from "../../src/session/message-v2"
+import { Instruction } from "../../src/session"
+import type { MessageV2 } from "../../src/session"
 import { Instance } from "../../src/project/instance"
 import { MessageID, PartID, SessionID } from "../../src/session/schema"
 import { Global } from "../../src/global"

+ 2 - 2
packages/opencode/test/session/llm.test.ts

@@ -4,7 +4,7 @@ import { tool, type ModelMessage } from "ai"
 import { Cause, Effect, Exit, Stream } from "effect"
 import z from "zod"
 import { makeRuntime } from "../../src/effect/run-service"
-import { LLM } from "../../src/session/llm"
+import { LLM } from "../../src/session"
 import { Instance } from "../../src/project/instance"
 import { Provider } from "../../src/provider"
 import { ProviderTransform } from "../../src/provider/transform"
@@ -13,7 +13,7 @@ import { ProviderID, ModelID } from "../../src/provider/schema"
 import { Filesystem } from "../../src/util/filesystem"
 import { tmpdir } from "../fixture/fixture"
 import type { Agent } from "../../src/agent/agent"
-import { MessageV2 } from "../../src/session/message-v2"
+import { MessageV2 } from "../../src/session"
 import { SessionID, MessageID } from "../../src/session/schema"
 import { AppRuntime } from "../../src/effect/app-runtime"
 

+ 1 - 1
packages/opencode/test/session/message-v2.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import { APICallError } from "ai"
-import { MessageV2 } from "../../src/session/message-v2"
+import { MessageV2 } from "../../src/session"
 import type { Provider } from "../../src/provider"
 import { ModelID, ProviderID } from "../../src/provider/schema"
 import { SessionID, MessageID, PartID } from "../../src/session/schema"

+ 1 - 1
packages/opencode/test/session/messages-pagination.test.ts

@@ -3,7 +3,7 @@ import { Effect } from "effect"
 import path from "path"
 import { Instance } from "../../src/project/instance"
 import { Session as SessionNs } from "../../src/session"
-import { MessageV2 } from "../../src/session/message-v2"
+import { MessageV2 } from "../../src/session"
 import { MessageID, PartID, type SessionID } from "../../src/session/schema"
 import { ModelID, ProviderID } from "../../src/provider/schema"
 import { Log } from "../../src/util/log"

+ 5 - 5
packages/opencode/test/session/processor-effect.test.ts

@@ -11,12 +11,12 @@ import { Plugin } from "../../src/plugin"
 import { Provider } from "../../src/provider"
 import { ModelID, ProviderID } from "../../src/provider/schema"
 import { Session } from "../../src/session"
-import { LLM } from "../../src/session/llm"
-import { MessageV2 } from "../../src/session/message-v2"
-import { SessionProcessor } from "../../src/session/processor"
+import { LLM } from "../../src/session"
+import { MessageV2 } from "../../src/session"
+import { SessionProcessor } from "../../src/session"
 import { MessageID, PartID, SessionID } from "../../src/session/schema"
-import { SessionStatus } from "../../src/session/status"
-import { SessionSummary } from "../../src/session/summary"
+import { SessionStatus } from "../../src/session"
+import { SessionSummary } from "../../src/session"
 import { Snapshot } from "../../src/snapshot"
 import { Log } from "../../src/util/log"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"

+ 12 - 12
packages/opencode/test/session/prompt-effect.test.ts

@@ -16,22 +16,22 @@ import { Provider as ProviderSvc } from "../../src/provider"
 import { Env } from "../../src/env"
 import { ModelID, ProviderID } from "../../src/provider/schema"
 import { Question } from "../../src/question"
-import { Todo } from "../../src/session/todo"
+import { Todo } from "../../src/session"
 import { Session } from "../../src/session"
-import { LLM } from "../../src/session/llm"
-import { MessageV2 } from "../../src/session/message-v2"
+import { LLM } from "../../src/session"
+import { MessageV2 } from "../../src/session"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-import { SessionCompaction } from "../../src/session/compaction"
-import { SessionSummary } from "../../src/session/summary"
-import { Instruction } from "../../src/session/instruction"
-import { SessionProcessor } from "../../src/session/processor"
-import { SessionPrompt } from "../../src/session/prompt"
-import { SessionRevert } from "../../src/session/revert"
-import { SessionRunState } from "../../src/session/run-state"
+import { SessionCompaction } from "../../src/session"
+import { SessionSummary } from "../../src/session"
+import { Instruction } from "../../src/session"
+import { SessionProcessor } from "../../src/session"
+import { SessionPrompt } from "../../src/session"
+import { SessionRevert } from "../../src/session"
+import { SessionRunState } from "../../src/session"
 import { MessageID, PartID, SessionID } from "../../src/session/schema"
-import { SessionStatus } from "../../src/session/status"
+import { SessionStatus } from "../../src/session"
 import { Skill } from "../../src/skill"
-import { SystemPrompt } from "../../src/session/system"
+import { SystemPrompt } from "../../src/session"
 import { Shell } from "../../src/shell/shell"
 import { Snapshot } from "../../src/snapshot"
 import { ToolRegistry } from "../../src/tool/registry"

+ 2 - 2
packages/opencode/test/session/prompt.test.ts

@@ -6,8 +6,8 @@ import { Effect, Layer } from "effect"
 import { Instance } from "../../src/project/instance"
 import { ModelID, ProviderID } from "../../src/provider/schema"
 import { Session } from "../../src/session"
-import { MessageV2 } from "../../src/session/message-v2"
-import { SessionPrompt } from "../../src/session/prompt"
+import { MessageV2 } from "../../src/session"
+import { SessionPrompt } from "../../src/session"
 import { Log } from "../../src/util/log"
 import { tmpdir } from "../fixture/fixture"
 

+ 3 - 3
packages/opencode/test/session/retry.test.ts

@@ -3,12 +3,12 @@ import type { NamedError } from "@opencode-ai/shared/util/error"
 import { APICallError } from "ai"
 import { setTimeout as sleep } from "node:timers/promises"
 import { Effect, Schedule } from "effect"
-import { SessionRetry } from "../../src/session/retry"
-import { MessageV2 } from "../../src/session/message-v2"
+import { SessionRetry } from "../../src/session"
+import { MessageV2 } from "../../src/session"
 import { ProviderID } from "../../src/provider/schema"
 import { AppRuntime } from "../../src/effect/app-runtime"
 import { SessionID } from "../../src/session/schema"
-import { SessionStatus } from "../../src/session/status"
+import { SessionStatus } from "../../src/session"
 import { Instance } from "../../src/project/instance"
 import { tmpdir } from "../fixture/fixture"
 

+ 2 - 2
packages/opencode/test/session/revert-compact.test.ts

@@ -4,8 +4,8 @@ import path from "path"
 import { Effect, Layer } from "effect"
 import { Session } from "../../src/session"
 import { ModelID, ProviderID } from "../../src/provider/schema"
-import { SessionRevert } from "../../src/session/revert"
-import { MessageV2 } from "../../src/session/message-v2"
+import { SessionRevert } from "../../src/session"
+import { MessageV2 } from "../../src/session"
 import { Snapshot } from "../../src/snapshot"
 import { Log } from "../../src/util/log"
 import { MessageID, PartID, SessionID } from "../../src/session/schema"

+ 1 - 1
packages/opencode/test/session/session.test.ts

@@ -4,7 +4,7 @@ import { Session as SessionNs } from "../../src/session"
 import { Bus } from "../../src/bus"
 import { Log } from "../../src/util/log"
 import { Instance } from "../../src/project/instance"
-import { MessageV2 } from "../../src/session/message-v2"
+import { MessageV2 } from "../../src/session"
 import { MessageID, PartID, type SessionID } from "../../src/session/schema"
 import { AppRuntime } from "../../src/effect/app-runtime"
 import { tmpdir } from "../fixture/fixture"

+ 12 - 12
packages/opencode/test/session/snapshot-tool-race.test.ts

@@ -17,11 +17,11 @@ import { FetchHttpClient } from "effect/unstable/http"
 import fs from "fs/promises"
 import path from "path"
 import { Session } from "../../src/session"
-import { LLM } from "../../src/session/llm"
-import { SessionPrompt } from "../../src/session/prompt"
-import { SessionRevert } from "../../src/session/revert"
-import { SessionSummary } from "../../src/session/summary"
-import { MessageV2 } from "../../src/session/message-v2"
+import { LLM } from "../../src/session"
+import { SessionPrompt } from "../../src/session"
+import { SessionRevert } from "../../src/session"
+import { SessionSummary } from "../../src/session"
+import { MessageV2 } from "../../src/session"
 import { Log } from "../../src/util/log"
 import { provideTmpdirServer } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
@@ -42,13 +42,13 @@ import { Provider as ProviderSvc } from "../../src/provider"
 import { Env } from "../../src/env"
 import { Question } from "../../src/question"
 import { Skill } from "../../src/skill"
-import { SystemPrompt } from "../../src/session/system"
-import { Todo } from "../../src/session/todo"
-import { SessionCompaction } from "../../src/session/compaction"
-import { Instruction } from "../../src/session/instruction"
-import { SessionProcessor } from "../../src/session/processor"
-import { SessionRunState } from "../../src/session/run-state"
-import { SessionStatus } from "../../src/session/status"
+import { SystemPrompt } from "../../src/session"
+import { Todo } from "../../src/session"
+import { SessionCompaction } from "../../src/session"
+import { Instruction } from "../../src/session"
+import { SessionProcessor } from "../../src/session"
+import { SessionRunState } from "../../src/session"
+import { SessionStatus } from "../../src/session"
 import { Snapshot } from "../../src/snapshot"
 import { ToolRegistry } from "../../src/tool/registry"
 import { Truncate } from "../../src/tool/truncate"

+ 2 - 2
packages/opencode/test/session/structured-output-integration.test.ts

@@ -2,10 +2,10 @@ import { describe, expect, test } from "bun:test"
 import path from "path"
 import { Effect, Layer } from "effect"
 import { Session } from "../../src/session"
-import { SessionPrompt } from "../../src/session/prompt"
+import { SessionPrompt } from "../../src/session"
 import { Log } from "../../src/util/log"
 import { Instance } from "../../src/project/instance"
-import { MessageV2 } from "../../src/session/message-v2"
+import { MessageV2 } from "../../src/session"
 
 const projectRoot = path.join(__dirname, "../..")
 Log.init({ print: false })

+ 2 - 2
packages/opencode/test/session/structured-output.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
-import { MessageV2 } from "../../src/session/message-v2"
-import { SessionPrompt } from "../../src/session/prompt"
+import { MessageV2 } from "../../src/session"
+import { SessionPrompt } from "../../src/session"
 import { SessionID, MessageID } from "../../src/session/schema"
 
 describe("structured-output.OutputFormat", () => {

+ 1 - 1
packages/opencode/test/session/system.test.ts

@@ -3,7 +3,7 @@ import path from "path"
 import { Effect } from "effect"
 import { Agent } from "../../src/agent/agent"
 import { Instance } from "../../src/project/instance"
-import { SystemPrompt } from "../../src/session/system"
+import { SystemPrompt } from "../../src/session"
 import { provideInstance, tmpdir } from "../fixture/fixture"
 
 function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {

+ 1 - 1
packages/opencode/test/tool/read.test.ts

@@ -9,7 +9,7 @@ import { LSP } from "../../src/lsp"
 import { Permission } from "../../src/permission"
 import { Instance } from "../../src/project/instance"
 import { SessionID, MessageID } from "../../src/session/schema"
-import { Instruction } from "../../src/session/instruction"
+import { Instruction } from "../../src/session"
 import { ReadTool } from "../../src/tool/read"
 import { Truncate } from "../../src/tool/truncate"
 import { Tool } from "../../src/tool/tool"

+ 2 - 2
packages/opencode/test/tool/task.test.ts

@@ -5,8 +5,8 @@ import { Config } from "../../src/config"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
 import { Instance } from "../../src/project/instance"
 import { Session } from "../../src/session"
-import { MessageV2 } from "../../src/session/message-v2"
-import type { SessionPrompt } from "../../src/session/prompt"
+import { MessageV2 } from "../../src/session"
+import type { SessionPrompt } from "../../src/session"
 import { MessageID, PartID } from "../../src/session/schema"
 import { ModelID, ProviderID } from "../../src/provider/schema"
 import { TaskTool, type TaskPromptOps } from "../../src/tool/task"

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor