Преглед на файлове

feat(core): add generic session inbox (#42109)

Dax преди 3 дни
родител
ревизия
a7642737c7
променени са 82 файла, в които са добавени 3294 реда и са изтрити 2121 реда
  1. 23 23
      packages/app/src/context/server-session-v2-reducer.test.ts
  2. 21 20
      packages/app/src/context/server-session-v2-reducer.ts
  3. 2 3
      packages/cli/src/acp/event.ts
  4. 2 2
      packages/cli/src/run/noninteractive.ts
  5. 18 18
      packages/cli/test/acp/event-behavior.test.ts
  6. 2 2
      packages/cli/test/acp/event.test.ts
  7. 12 12
      packages/cli/test/acp/permission-behavior.test.ts
  8. 8 8
      packages/cli/test/acp/service-usage.test.ts
  9. 10 10
      packages/cli/test/run/noninteractive.test.ts
  10. 2 2
      packages/client/script/build.ts
  11. 41 52
      packages/client/src/effect/api/api.ts
  12. 10 7
      packages/client/src/effect/generated/client.ts
  13. 1 1
      packages/client/src/effect/index.ts
  14. 23 23
      packages/client/src/promise/generated/client.ts
  15. 164 153
      packages/client/src/promise/generated/types.ts
  16. 4 2
      packages/client/test/effect.test.ts
  17. 11 11
      packages/client/test/promise.test.ts
  18. 251 38
      packages/core/schema.json
  19. 196 30
      packages/core/src/bus.ts
  20. 2 0
      packages/core/src/database/migration.gen.ts
  21. 30 0
      packages/core/src/database/migration/20260812181746_session_inbox.ts
  22. 18 0
      packages/core/src/database/schema.gen.ts
  23. 57 56
      packages/core/src/session.ts
  24. 10 6
      packages/core/src/session/compaction.ts
  25. 5 1
      packages/core/src/session/execution.ts
  26. 505 0
      packages/core/src/session/inbox.ts
  27. 4 6
      packages/core/src/session/message-updater.ts
  28. 0 545
      packages/core/src/session/pending.ts
  29. 29 49
      packages/core/src/session/projector.ts
  30. 71 29
      packages/core/src/session/runner/llm.ts
  31. 27 5
      packages/core/src/session/sql.ts
  32. 114 0
      packages/core/test/bus.test.ts
  33. 3 3
      packages/core/test/plugin/promise.test.ts
  34. 16 12
      packages/core/test/session-compact.test.ts
  35. 26 23
      packages/core/test/session-create.test.ts
  36. 14 11
      packages/core/test/session-generate.test.ts
  37. 18 14
      packages/core/test/session-move.test.ts
  38. 19 19
      packages/core/test/session-projector.test.ts
  39. 83 87
      packages/core/test/session-prompt.test.ts
  40. 2 2
      packages/core/test/session-runner-recorded.test.ts
  41. 64 29
      packages/core/test/session-runner.test.ts
  42. 2 2
      packages/core/test/session-skill.test.ts
  43. 5 5
      packages/core/test/session-title.test.ts
  44. 7 7
      packages/core/test/shared-schema.test.ts
  45. 3 3
      packages/core/test/tool-shell.test.ts
  46. 9 7
      packages/core/test/tool-subagent.test.ts
  47. 2 2
      packages/plugin/src/tui/context.ts
  48. 299 106
      packages/protocol/openapi.json
  49. 35 32
      packages/protocol/src/groups/session.ts
  50. 1 1
      packages/schema/src/index.ts
  51. 0 6
      packages/schema/src/session-delivery.ts
  52. 25 46
      packages/schema/src/session-event.ts
  53. 78 0
      packages/schema/src/session-inbox.ts
  54. 0 75
      packages/schema/src/session-pending.ts
  55. 34 12
      packages/schema/test/contract-hygiene.test.ts
  56. 4 6
      packages/schema/test/event-manifest.test.ts
  57. 1 1
      packages/sdk-next/src/index.ts
  58. 6 6
      packages/sdk-next/test/contract-identity.test.ts
  59. 7 7
      packages/sdk-next/test/embedded.test.ts
  60. 29 26
      packages/server/src/handlers/session.ts
  61. 1 1
      packages/tui/src/component/devtools-bar.tsx
  62. 3 3
      packages/tui/src/component/prompt/index.tsx
  63. 42 56
      packages/tui/src/context/data.tsx
  64. 2 2
      packages/tui/src/context/session-tabs.tsx
  65. 1 1
      packages/tui/src/mini/footer.prompt.tsx
  66. 1 1
      packages/tui/src/mini/footer.ts
  67. 5 5
      packages/tui/src/mini/footer.view.tsx
  68. 1 1
      packages/tui/src/mini/runtime.lifecycle.ts
  69. 4 4
      packages/tui/src/mini/runtime.ts
  70. 8 8
      packages/tui/src/mini/stream-v2.subagent.ts
  71. 29 35
      packages/tui/src/mini/stream-v2.transport.ts
  72. 2 2
      packages/tui/src/mini/types.ts
  73. 10 10
      packages/tui/src/routes/session/index.tsx
  74. 8 13
      packages/tui/src/routes/session/rows.ts
  75. 3 3
      packages/tui/test/app-lifecycle.test.tsx
  76. 44 33
      packages/tui/test/cli/tui/data.test.tsx
  77. 9 9
      packages/tui/test/context/session-tabs.test.tsx
  78. 5 5
      packages/tui/test/mini/footer.view.test.tsx
  79. 52 52
      packages/tui/test/mini/stream-v2.transport.test.ts
  80. 1 1
      packages/workerd-spike/test/spike.test.ts
  81. 299 106
      packages/www/openapi.json
  82. 299 106
      packages/www/public/openapi.json

+ 23 - 23
packages/app/src/context/server-session-v2-reducer.test.ts

@@ -18,18 +18,18 @@ describe("v2 session reducer", () => {
     apply({
       ...base,
       id: "evt_admitted",
-      type: "session.input.admitted",
+      type: "session.inbox.enqueued",
       data: {
         sessionID: "ses_1",
-        inputID: "msg_user",
-        input: { type: "user", delivery: "steer", data: { text: "hello" } },
+        inboxID: "msg_user",
+        item: { type: "user", delivery: "steer", payload: { text: "hello" } },
       },
     })
     apply({
       ...base,
       id: "evt_promoted",
-      type: "session.input.promoted",
-      data: { sessionID: "ses_1", inputID: "msg_user" },
+      type: "session.inbox.delivered",
+      data: { sessionID: "ses_1", inboxID: "msg_user" },
     })
     apply({
       ...base,
@@ -203,8 +203,8 @@ describe("v2 session reducer", () => {
       event({
         ...base,
         id: "evt_promoted",
-        type: "session.input.promoted",
-        data: { sessionID: "ses_1", inputID: "msg_user" },
+        type: "session.inbox.delivered",
+        data: { sessionID: "ses_1", inboxID: "msg_user" },
       }),
     )
 
@@ -218,11 +218,11 @@ describe("v2 session reducer", () => {
       event({
         ...base,
         id: "evt_admitted",
-        type: "session.input.admitted",
+        type: "session.inbox.enqueued",
         data: {
           sessionID: "ses_1",
-          inputID: "msg_user",
-          input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
+          inboxID: "msg_user",
+          item: { type: "user", delivery: "queue", payload: { text: "cancel me" } },
         },
       }),
     )
@@ -231,8 +231,8 @@ describe("v2 session reducer", () => {
       event({
         ...base,
         id: "evt_cancelled",
-        type: "session.input.cancelled",
-        data: { sessionID: "ses_1", inputID: "msg_user" },
+        type: "session.inbox.cancelled",
+        data: { sessionID: "ses_1", inboxID: "msg_user" },
       }),
     )
     const result = reducer.reduce(
@@ -240,8 +240,8 @@ describe("v2 session reducer", () => {
       event({
         ...base,
         id: "evt_promoted",
-        type: "session.input.promoted",
-        data: { sessionID: "ses_1", inputID: "msg_user" },
+        type: "session.inbox.delivered",
+        data: { sessionID: "ses_1", inboxID: "msg_user" },
       }),
     )
 
@@ -255,11 +255,11 @@ describe("v2 session reducer", () => {
       event({
         ...base,
         id: "evt_admitted",
-        type: "session.input.admitted",
+        type: "session.inbox.enqueued",
         data: {
           sessionID: "ses_1",
-          inputID: "msg_user",
-          input: { type: "user", delivery: "queue", data: { text: "steer me" } },
+          inboxID: "msg_user",
+          item: { type: "user", delivery: "queue", payload: { text: "steer me" } },
         },
       }),
     )
@@ -268,8 +268,8 @@ describe("v2 session reducer", () => {
       event({
         ...base,
         id: "evt_steered",
-        type: "session.input.steered",
-        data: { sessionID: "ses_1", inputID: "msg_user" },
+        type: "session.inbox.delivery.changed",
+        data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "steer" },
       }),
     )
     reducer.reduce(
@@ -277,8 +277,8 @@ describe("v2 session reducer", () => {
       event({
         ...base,
         id: "evt_queued",
-        type: "session.input.queued",
-        data: { sessionID: "ses_1", inputID: "msg_user" },
+        type: "session.inbox.delivery.changed",
+        data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "queue" },
       }),
     )
 
@@ -287,8 +287,8 @@ describe("v2 session reducer", () => {
       event({
         ...base,
         id: "evt_promoted",
-        type: "session.input.promoted",
-        data: { sessionID: "ses_1", inputID: "msg_user" },
+        type: "session.inbox.delivered",
+        data: { sessionID: "ses_1", inboxID: "msg_user" },
       }),
     )
 

+ 21 - 20
packages/app/src/context/server-session-v2-reducer.ts

@@ -1,4 +1,4 @@
-import type { OpenCodeEvent, SessionMessageInfo, SessionPendingMessage } from "@opencode-ai/client/promise"
+import type { OpenCodeEvent, SessionInboxItem, SessionMessageInfo } from "@opencode-ai/client/promise"
 
 type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
 type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
@@ -12,7 +12,7 @@ export type V2SessionReduction = {
 }
 
 export function createV2SessionReducer() {
-  const pending = new Map<string, SessionPendingMessage>()
+  const pending = new Map<string, SessionInboxItem>()
 
   const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => {
     if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
@@ -26,32 +26,33 @@ export function createV2SessionReducer() {
       result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
 
     switch (event.type) {
-      case "session.input.admitted":
-        pending.set(key(sessionID, event.data.inputID), event.data.input)
+      case "session.inbox.enqueued":
+        pending.set(key(sessionID, event.data.inboxID), event.data.item)
         return result([...source])
-      case "session.input.cancelled":
-        pending.delete(key(sessionID, event.data.inputID))
+      case "session.inbox.cancelled":
+        pending.delete(key(sessionID, event.data.inboxID))
         return
-      case "session.input.promoted": {
-        const input = pending.get(key(sessionID, event.data.inputID))
-        pending.delete(key(sessionID, event.data.inputID))
-        if (!input) return { ...result([...source]), missing: event.data.inputID }
+      case "session.inbox.delivered": {
+        const input = pending.get(key(sessionID, event.data.inboxID))
+        pending.delete(key(sessionID, event.data.inboxID))
+        if (!input) return { ...result([...source]), missing: event.data.inboxID }
         if (input.type === "user")
           return append({
-            id: event.data.inputID,
+            id: event.data.inboxID,
             type: "user",
-            metadata: input.data.metadata,
-            text: input.data.text,
-            files: input.data.files,
-            agents: input.data.agents,
+            metadata: input.payload.metadata,
+            text: input.payload.text,
+            files: input.payload.files,
+            agents: input.payload.agents,
             time: { created: event.created },
           })
+        if (input.type !== "synthetic") return result([...source])
         return append({
-          id: event.data.inputID,
+          id: event.data.inboxID,
           type: "synthetic",
-          metadata: input.data.metadata,
-          text: input.data.text,
-          description: input.data.description,
+          metadata: input.payload.metadata,
+          text: input.payload.text,
+          description: input.payload.description,
           time: { created: event.created },
         })
       }
@@ -351,7 +352,7 @@ export function createV2SessionReducer() {
           metadata: event.metadata,
           reason: event.data.reason,
           summary: "",
-          recent: event.data.recent,
+          recent: event.data.recent ?? "",
           time: { created: event.created },
         })
       case "session.compaction.delta":

+ 2 - 3
packages/cli/src/acp/event.ts

@@ -538,9 +538,8 @@ async function replayMessage(
 }
 
 function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
-  if (start.type === "input") return event.type === "session.input.promoted" && event.data.inputID === start.id
-  if (start.type === "compaction")
-    return event.type === "session.compaction.admitted" && event.data.inputID === start.id
+  if (start.type === "input") return event.type === "session.inbox.delivered" && event.data.inboxID === start.id
+  if (start.type === "compaction") return event.type === "session.inbox.delivered" && event.data.inboxID === start.id
   return event.type === "session.skill.activated" && event.id === start.id.replace(/^msg_/, "evt_")
 }
 

+ 2 - 2
packages/cli/src/run/noninteractive.ts

@@ -195,8 +195,8 @@ export async function runNonInteractivePrompt(input: Input) {
       if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
       const time = toMillis("created" in event ? event.created : undefined)
 
-      if (event.type === "session.input.promoted") {
-        if (event.data.inputID === messageID) {
+      if (event.type === "session.inbox.delivered") {
+        if (event.data.inboxID === messageID) {
           promoted = true
           prePromotionError = undefined
           continue

+ 18 - 18
packages/cli/test/acp/event-behavior.test.ts

@@ -22,8 +22,8 @@ describe("acp event behavior", () => {
             delta: "before admission",
           }),
         )
-        send(durableEvent("session.input.promoted", { sessionID: "ses_b", inputID: id }))
-        send(durableEvent("session.input.promoted", { sessionID: "ses_a", inputID: "input_other" }))
+        send(durableEvent("session.inbox.delivered", { sessionID: "ses_b", inboxID: id }))
+        send(durableEvent("session.inbox.delivered", { sessionID: "ses_a", inboxID: "input_other" }))
         send(
           ephemeralEvent("session.text.delta", {
             sessionID: "ses_a",
@@ -32,7 +32,7 @@ describe("acp event behavior", () => {
             delta: "wrong input",
           }),
         )
-        send(durableEvent("session.input.promoted", { sessionID: "ses_a", inputID: id }))
+        send(durableEvent("session.inbox.delivered", { sessionID: "ses_a", inboxID: id }))
         send(
           ephemeralEvent("session.text.delta", {
             sessionID: "ses_b",
@@ -68,7 +68,7 @@ describe("acp event behavior", () => {
         fixture,
         connection: recordingConnection(updates),
         sessionID: "ses_a",
-        inputID: "input_a",
+        inboxID: "input_a",
       })
 
       expect(fixture.requests.slice(0, 2).map((request) => request.path)).toEqual([
@@ -99,7 +99,7 @@ describe("acp event behavior", () => {
     const updates: SessionUpdateParams[] = []
     const fixture = createSseFixture({
       async onPrompt({ id, send }) {
-        send(durableEvent("session.input.promoted", { sessionID: "ses_order", inputID: id }))
+        send(durableEvent("session.inbox.delivered", { sessionID: "ses_order", inboxID: id }))
         send(
           ephemeralEvent("session.reasoning.delta", {
             sessionID: "ses_order",
@@ -148,7 +148,7 @@ describe("acp event behavior", () => {
       },
       requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
     } satisfies Connection
-    const result = turn({ fixture, connection, sessionID: "ses_order", inputID: "input_order" })
+    const result = turn({ fixture, connection, sessionID: "ses_order", inboxID: "input_order" })
 
     try {
       await withTimeout(firstUpdate.promise, "first ordered update was not delivered")
@@ -195,7 +195,7 @@ describe("acp event behavior", () => {
     const updates: SessionUpdateParams[] = []
     const fixture = createSseFixture({
       onPrompt({ id, send }) {
-        send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
+        send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
         send(
           durableEvent("session.created", {
             sessionID: "ses_child",
@@ -240,7 +240,7 @@ describe("acp event behavior", () => {
         fixture,
         connection: recordingConnection(updates),
         sessionID: "ses_parent",
-        inputID: "input_parent",
+        inboxID: "input_parent",
       })
 
       expect(updates.map((item) => [item.sessionId, item.update.sessionUpdate])).toEqual([
@@ -276,7 +276,7 @@ describe("acp event behavior", () => {
     const completed = Promise.withResolvers<void>()
     const fixture = createSseFixture({
       onPrompt({ id, send }) {
-        send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
+        send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
         send(
           durableEvent("session.created", {
             sessionID: "ses_background",
@@ -292,7 +292,7 @@ describe("acp event behavior", () => {
         fixture,
         connection: recordingConnection(updates),
         sessionID: "ses_parent",
-        inputID: "input_parent",
+        inboxID: "input_parent",
         childSessionUpdate: async (update) => {
           childUpdates.push(update)
           if (update.type === "status" && update.status === "completed") completed.resolve()
@@ -370,7 +370,7 @@ describe("acp event behavior", () => {
     const updates: SessionUpdateParams[] = []
     const fixture = createSseFixture({
       onPrompt({ id, send }) {
-        send(durableEvent("session.input.promoted", { sessionID: "ses_tools", inputID: id }))
+        send(durableEvent("session.inbox.delivered", { sessionID: "ses_tools", inboxID: id }))
         send(
           durableEvent("session.tool.input.started", {
             sessionID: "ses_tools",
@@ -460,7 +460,7 @@ describe("acp event behavior", () => {
         fixture,
         connection: recordingConnection(updates),
         sessionID: "ses_tools",
-        inputID: "input_tools",
+        inboxID: "input_tools",
       })
 
       expect(
@@ -597,7 +597,7 @@ describe("acp event behavior", () => {
     const control: TurnControl = { cancelled: false, admission: new AbortController() }
     const fixture = createSseFixture({
       onPrompt({ id, send }) {
-        send(durableEvent("session.input.promoted", { sessionID: "ses_cancel", inputID: id }))
+        send(durableEvent("session.inbox.delivered", { sessionID: "ses_cancel", inboxID: id }))
       },
       onInterrupt({ sessionID, send }) {
         send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
@@ -680,7 +680,7 @@ describe("acp event behavior", () => {
   test("cancels unsupported session forms so execution can continue", async () => {
     const fixture = createSseFixture({
       onPrompt({ id, send }) {
-        send(durableEvent("session.input.promoted", { sessionID: "ses_form", inputID: id }))
+        send(durableEvent("session.inbox.delivered", { sessionID: "ses_form", inboxID: id }))
         send(
           ephemeralEvent("form.created", {
             form: {
@@ -704,7 +704,7 @@ describe("acp event behavior", () => {
         fixture,
         connection: recordingConnection([]),
         sessionID: "ses_form",
-        inputID: "input_form",
+        inboxID: "input_form",
       })
 
       expect(response.stopReason).toBe("end_turn")
@@ -730,7 +730,7 @@ function turn(input: {
   readonly fixture: Fixture
   readonly connection: Connection
   readonly sessionID: string
-  readonly inputID: string
+  readonly inboxID: string
   readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
 }) {
   return streamTurn({
@@ -738,12 +738,12 @@ function turn(input: {
     connection: input.connection,
     sessionID: input.sessionID,
     cwd: "/workspace",
-    start: { type: "input", id: input.inputID },
+    start: { type: "input", id: input.inboxID },
     writeTextFile: false,
     control: { cancelled: false, admission: new AbortController() },
     childSessionUpdate: input.childSessionUpdate,
     submit: (signal) =>
-      input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
+      input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inboxID, text: "hello" }, { signal }),
   })
 }
 

+ 2 - 2
packages/cli/test/acp/event.test.ts

@@ -34,8 +34,8 @@ test("acp prompt resolves after ordered turn updates", async () => {
           send(events, {
             id: "evt_promoted",
             created: 1,
-            type: "session.input.promoted",
-            data: { sessionID: "ses_test", inputID: id },
+            type: "session.inbox.delivered",
+            data: { sessionID: "ses_test", inboxID: id },
           })
           send(events, {
             id: "evt_text",

+ 12 - 12
packages/cli/test/acp/permission-behavior.test.ts

@@ -38,7 +38,7 @@ describe("acp permission behavior", () => {
     const permissionRequests: RequestPermissionRequest[] = []
     const fixture = createSseFixture({
       onPrompt({ id, send }) {
-        send(durableEvent("session.input.promoted", { sessionID: "ses_allow", inputID: id }))
+        send(durableEvent("session.inbox.delivered", { sessionID: "ses_allow", inboxID: id }))
         send(
           permissionAsked("ses_allow", "perm_once", {
             action: "shell",
@@ -112,7 +112,7 @@ describe("acp permission behavior", () => {
     const permissionRequests: RequestPermissionRequest[] = []
     const fixture = createSseFixture({
       onPrompt({ id, send }) {
-        send(durableEvent("session.input.promoted", { sessionID: "ses_external", inputID: id }))
+        send(durableEvent("session.inbox.delivered", { sessionID: "ses_external", inboxID: id }))
         send(
           permissionAsked("ses_external", "perm_external", {
             action: "external_directory",
@@ -157,7 +157,7 @@ describe("acp permission behavior", () => {
     const permissionRequests: RequestPermissionRequest[] = []
     const fixture = createSseFixture({
       onPrompt({ id, send }) {
-        send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
+        send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
         send(
           durableEvent("session.created", {
             sessionID: "ses_child",
@@ -219,7 +219,7 @@ describe("acp permission behavior", () => {
     const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
     const fixture = createSseFixture({
       onPrompt({ id, send }) {
-        send(durableEvent("session.input.promoted", { sessionID: "ses_edit", inputID: id }))
+        send(durableEvent("session.inbox.delivered", { sessionID: "ses_edit", inboxID: id }))
         send(
           durableEvent("session.tool.input.started", {
             sessionID: "ses_edit",
@@ -309,7 +309,7 @@ describe("acp permission behavior", () => {
     const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
     const fixture = createSseFixture({
       onPrompt({ id, send }) {
-        send(durableEvent("session.input.promoted", { sessionID: "ses_patch", inputID: id }))
+        send(durableEvent("session.inbox.delivered", { sessionID: "ses_patch", inboxID: id }))
         send(
           durableEvent("session.tool.input.started", {
             sessionID: "ses_patch",
@@ -389,7 +389,7 @@ describe("acp permission behavior", () => {
   test("rejects explicit rejection, cancellation, and permission UI failure", async () => {
     const fixture = createSseFixture({
       onPrompt({ id, send }) {
-        send(durableEvent("session.input.promoted", { sessionID: "ses_reject", inputID: id }))
+        send(durableEvent("session.inbox.delivered", { sessionID: "ses_reject", inboxID: id }))
         send(permissionAsked("ses_reject", "perm_selected_reject"))
         send(permissionAsked("ses_reject", "perm_cancelled"))
         send(permissionAsked("ses_reject", "perm_failed"))
@@ -426,7 +426,7 @@ describe("acp permission behavior", () => {
     const permissionRequests: RequestPermissionRequest[] = []
     const fixture = createSseFixture({
       onPrompt({ id, send }) {
-        send(durableEvent("session.input.promoted", { sessionID: "ses_serial", inputID: id }))
+        send(durableEvent("session.inbox.delivered", { sessionID: "ses_serial", inboxID: id }))
         send(permissionAsked("ses_serial", "perm_1"))
         send(permissionAsked("ses_serial", "perm_2"))
         send(durableEvent("session.execution.succeeded", { sessionID: "ses_serial" }))
@@ -477,8 +477,8 @@ describe("acp permission behavior", () => {
         const blockedID = promptIDs.get("ses_blocked")
         const freeID = promptIDs.get("ses_free")
         if (!blockedID || !freeID) throw new Error("both permission test prompts must be registered")
-        send(durableEvent("session.input.promoted", { sessionID: "ses_blocked", inputID: blockedID }))
-        send(durableEvent("session.input.promoted", { sessionID: "ses_free", inputID: freeID }))
+        send(durableEvent("session.inbox.delivered", { sessionID: "ses_blocked", inboxID: blockedID }))
+        send(durableEvent("session.inbox.delivered", { sessionID: "ses_free", inboxID: freeID }))
         send(permissionAsked("ses_blocked", "perm_blocked"))
         send(
           ephemeralEvent("session.text.delta", {
@@ -538,16 +538,16 @@ describe("acp permission behavior", () => {
   })
 })
 
-function startTurn(fixture: Fixture, connection: Connection, sessionID: string, inputID: string, cwd = "/workspace") {
+function startTurn(fixture: Fixture, connection: Connection, sessionID: string, inboxID: string, cwd = "/workspace") {
   return streamTurn({
     client: fixture.client,
     connection,
     sessionID,
     cwd,
-    start: { type: "input", id: inputID },
+    start: { type: "input", id: inboxID },
     writeTextFile: true,
     control: { cancelled: false, admission: new AbortController() },
-    submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }),
+    submit: (signal) => fixture.client.session.prompt({ sessionID, id: inboxID, text: "hello" }, { signal }),
   })
 }
 

+ 8 - 8
packages/cli/test/acp/service-usage.test.ts

@@ -15,8 +15,8 @@ describe("acp service prompt routing and usage", () => {
           const id = requestID(request)
           completeTurn(context, "ses_routes", {
             id: `evt_${id}`,
-            type: "session.input.promoted",
-            data: { sessionID: "ses_routes", inputID: id },
+            type: "session.inbox.delivered",
+            data: { sessionID: "ses_routes", inboxID: id },
           })
           return Response.json({ data: {} })
         }
@@ -33,8 +33,8 @@ describe("acp service prompt routing and usage", () => {
           const id = requestID(request)
           completeTurn(context, "ses_routes", {
             id: `evt_${id}`,
-            type: "session.compaction.admitted",
-            data: { sessionID: "ses_routes", inputID: id },
+            type: "session.inbox.delivered",
+            data: { sessionID: "ses_routes", inboxID: id },
           })
           return Response.json({ data: {} })
         }
@@ -95,8 +95,8 @@ describe("acp service prompt routing and usage", () => {
           const id = requestID(request)
           context.send({
             id: `evt_${id}`,
-            type: "session.input.promoted",
-            data: { sessionID: "ses_usage", inputID: id },
+            type: "session.inbox.delivered",
+            data: { sessionID: "ses_usage", inboxID: id },
           })
           context.send({
             id: "evt_step",
@@ -189,8 +189,8 @@ describe("acp service prompt routing and usage", () => {
           const id = requestID(request)
           context.send({
             id: `evt_${id}`,
-            type: "session.input.promoted",
-            data: { sessionID: "ses_usage_failure", inputID: id },
+            type: "session.inbox.delivered",
+            data: { sessionID: "ses_usage_failure", inboxID: id },
           })
           context.send({
             id: "evt_step_failure",

+ 10 - 10
packages/cli/test/run/noninteractive.test.ts

@@ -28,13 +28,13 @@ function formCreated(info: FormInfo, eventLocation = location): V2Event {
   return { id: `evt_${info.id}`, created: 0, type: "form.created", location: eventLocation, data: { form: info } }
 }
 
-function prompted(inputID: string): V2Event {
+function prompted(inboxID: string): V2Event {
   return {
     id: "evt_prompted",
     created: 0,
-    type: "session.input.promoted",
+    type: "session.inbox.delivered",
     durable: { aggregateID: "ses_1", seq: 0, version: 1 },
-    data: { sessionID: "ses_1", inputID },
+    data: { sessionID: "ses_1", inboxID },
   }
 }
 
@@ -98,9 +98,9 @@ function executionFailed(message: string): V2Event {
   }
 }
 
-function failedTool(inputID: string): V2Event[] {
+function failedTool(inboxID: string): V2Event[] {
   return [
-    prompted(inputID),
+    prompted(inboxID),
     {
       id: "evt_failed_tool_input",
       created: 1,
@@ -156,10 +156,10 @@ function failedTool(inputID: string): V2Event[] {
   ]
 }
 
-function successfulGrep(inputID: string): V2Event[] {
+function successfulGrep(inboxID: string): V2Event[] {
   const text = "Found 2 matches\n/src/a.ts:\n  Line 1: needle\n/src/b.ts:\n  Line 2: needle"
   return [
-    prompted(inputID),
+    prompted(inboxID),
     {
       id: "evt_grep_input",
       created: 1,
@@ -206,7 +206,7 @@ function successfulGrep(inputID: string): V2Event[] {
 // Runs one non-interactive prompt against a mocked SDK. `turn` produces the
 // live events the prompt admission triggers, keyed by the generated message ID.
 async function run(input: {
-  turn: (inputID: string) => V2Event[]
+  turn: (inboxID: string) => V2Event[]
   pendingForms?: FormInfo[]
   attached?: boolean
   format?: "default" | "json"
@@ -214,7 +214,7 @@ async function run(input: {
   cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
   renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
   renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
-  messages?: (inputID: string) => SessionMessageInfo[]
+  messages?: (inboxID: string) => SessionMessageInfo[]
   wait?: () => Promise<void>
   terminalDelay?: number
 }) {
@@ -515,7 +515,7 @@ describe("runNonInteractivePrompt", () => {
     const rendered: SessionMessageAssistantTool[] = []
     const failed: SessionMessageAssistantTool[] = []
     await capture({
-      turn: (inputID) => failedTool(inputID).filter((event) => event.type !== "session.tool.progress"),
+      turn: (inboxID) => failedTool(inboxID).filter((event) => event.type !== "session.tool.progress"),
       renderTool: (part) => {
         rendered.push(part)
         return Promise.resolve()

+ 2 - 2
packages/client/script/build.ts

@@ -30,7 +30,7 @@ import { Reference } from "@opencode-ai/schema/reference"
 import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
 import { Session } from "@opencode-ai/schema/session"
 import { SessionMessage } from "@opencode-ai/schema/session-message"
-import { SessionPending } from "@opencode-ai/schema/session-pending"
+import { SessionInbox } from "@opencode-ai/schema/session-inbox"
 import { Shell } from "@opencode-ai/schema/shell"
 import { Skill } from "@opencode-ai/schema/skill"
 import { Vcs } from "@opencode-ai/schema/vcs"
@@ -69,7 +69,7 @@ const effectTypeReferences = [
   ...namespaceTypes("Reference", "@opencode-ai/schema/reference", Reference),
   ...namespaceTypes("Session", "@opencode-ai/schema/session", Session),
   ...namespaceTypes("SessionMessage", "@opencode-ai/schema/session-message", SessionMessage),
-  ...namespaceTypes("SessionPending", "@opencode-ai/schema/session-pending", SessionPending),
+  ...namespaceTypes("SessionInbox", "@opencode-ai/schema/session-inbox", SessionInbox),
   ...namespaceTypes("Shell", "@opencode-ai/schema/shell", Shell),
   ...namespaceTypes("Skill", "@opencode-ai/schema/skill", Skill),
   ...namespaceTypes("Vcs", "@opencode-ai/schema/vcs", Vcs),

+ 41 - 52
packages/client/src/effect/api/api.ts

@@ -11,9 +11,9 @@ import type { RelativePath } from "@opencode-ai/schema/schema"
 import type { Brand } from "effect"
 import type { Model } from "@opencode-ai/schema/model"
 import type { SessionMessage } from "@opencode-ai/schema/session-message"
+import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
 import type { PromptInput } from "@opencode-ai/schema/prompt-input"
 import type { AgentAttachment } from "@opencode-ai/schema/prompt"
-import type { SessionPending } from "@opencode-ai/schema/session-pending"
 import type { Skill } from "@opencode-ai/schema/skill"
 import type { Event } from "@opencode-ai/schema/event"
 import type { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
@@ -170,6 +170,7 @@ export type Endpoint5_11Input = {
   readonly sessionID: Session.ID
   readonly directory: AbsolutePath
   readonly workspaceID?: Workspace.ID | undefined
+  readonly delivery?: SessionInbox.Delivery | undefined
 }
 export type Endpoint5_11Output = void
 export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
@@ -182,10 +183,10 @@ export type Endpoint5_12Input = {
   readonly agents?: ReadonlyArray<AgentAttachment> | undefined
   readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
   readonly metadata?: { readonly [x: string]: unknown } | undefined
-  readonly delivery?: "steer" | "queue" | undefined
+  readonly delivery?: SessionInbox.Delivery | undefined
   readonly resume?: boolean | undefined
 }
-export type Endpoint5_12Output = SessionPending.User
+export type Endpoint5_12Output = SessionInbox.User
 export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
 
 export type Endpoint5_13Input = {
@@ -198,10 +199,10 @@ export type Endpoint5_13Input = {
   readonly files?: ReadonlyArray<PromptInput.FileAttachment> | undefined
   readonly agents?: ReadonlyArray<AgentAttachment> | undefined
   readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
-  readonly delivery?: "steer" | "queue" | undefined
+  readonly delivery?: SessionInbox.Delivery | undefined
   readonly resume?: boolean | undefined
 }
-export type Endpoint5_13Output = SessionPending.User
+export type Endpoint5_13Output = SessionInbox.User
 export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
 
 export type Endpoint5_14Input = {
@@ -219,10 +220,10 @@ export type Endpoint5_15Input = {
   readonly text: string
   readonly description?: string | undefined
   readonly metadata?: { readonly [x: string]: unknown } | undefined
-  readonly delivery?: "steer" | "queue" | undefined
+  readonly delivery?: SessionInbox.Delivery | undefined
   readonly resume?: boolean | undefined
 }
-export type Endpoint5_15Output = SessionPending.Synthetic
+export type Endpoint5_15Output = SessionInbox.Synthetic
 export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
 
 export type Endpoint5_16Input = {
@@ -233,8 +234,12 @@ export type Endpoint5_16Input = {
 export type Endpoint5_16Output = void
 export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
 
-export type Endpoint5_17Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
-export type Endpoint5_17Output = SessionPending.Compaction
+export type Endpoint5_17Input = {
+  readonly sessionID: Session.ID
+  readonly id?: SessionMessage.ID | undefined
+  readonly delivery?: SessionInbox.Delivery | undefined
+}
+export type Endpoint5_17Output = SessionInbox.Compaction
 export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
 
 export type Endpoint5_18Input = { readonly sessionID: Session.ID }
@@ -262,22 +267,20 @@ export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
 export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
 
 export type Endpoint5_23Input = { readonly sessionID: Session.ID }
-export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
-export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
+export type Endpoint5_23Output = ReadonlyArray<SessionInbox.Info>
+export type SessionInboxListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
 
-export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
+export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
 export type Endpoint5_24Output = void
-export type SessionPendingCancelOperation<E = never> = (
-  input: Endpoint5_24Input,
-) => Effect.Effect<Endpoint5_24Output, E>
+export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
 
-export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
+export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
 export type Endpoint5_25Output = void
-export type SessionPendingSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
+export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
 
-export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
+export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
 export type Endpoint5_26Output = void
-export type SessionPendingQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
+export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
 
 export type Endpoint5_27Input = { readonly sessionID: Session.ID }
 export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
@@ -368,7 +371,7 @@ export type Endpoint5_31Output =
           readonly data: {
             readonly sessionID: Session.ID
             readonly location: Location.Ref
-            readonly projectID?: Project.ID | undefined
+            readonly projectID: Project.ID
             readonly subpath?: RelativePath | undefined
           }
         }
@@ -410,50 +413,45 @@ export type Endpoint5_31Output =
           readonly id: Event.ID
           readonly created: DateTime.Utc
           readonly metadata?: { readonly [x: string]: unknown } | undefined
-          readonly type: "session.input.promoted"
+          readonly type: "session.inbox.delivered"
           readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
           readonly location?: Location.Ref | undefined
-          readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
+          readonly data: { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
         }
       | {
           readonly id: Event.ID
           readonly created: DateTime.Utc
           readonly metadata?: { readonly [x: string]: unknown } | undefined
-          readonly type: "session.input.admitted"
+          readonly type: "session.inbox.enqueued"
           readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
           readonly location?: Location.Ref | undefined
           readonly data: {
             readonly sessionID: Session.ID
-            readonly inputID: SessionMessage.ID
-            readonly input: SessionPending.Message
+            readonly inboxID: SessionMessage.ID
+            readonly item: SessionInbox.Item
           }
         }
       | {
           readonly id: Event.ID
           readonly created: DateTime.Utc
           readonly metadata?: { readonly [x: string]: unknown } | undefined
-          readonly type: "session.input.cancelled"
-          readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
-          readonly location?: Location.Ref | undefined
-          readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
-        }
-      | {
-          readonly id: Event.ID
-          readonly created: DateTime.Utc
-          readonly metadata?: { readonly [x: string]: unknown } | undefined
-          readonly type: "session.input.steered"
+          readonly type: "session.inbox.cancelled"
           readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
           readonly location?: Location.Ref | undefined
-          readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
+          readonly data: { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
         }
       | {
           readonly id: Event.ID
           readonly created: DateTime.Utc
           readonly metadata?: { readonly [x: string]: unknown } | undefined
-          readonly type: "session.input.queued"
+          readonly type: "session.inbox.delivery.changed"
           readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
           readonly location?: Location.Ref | undefined
-          readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
+          readonly data: {
+            readonly sessionID: Session.ID
+            readonly inboxID: SessionMessage.ID
+            readonly delivery: SessionInbox.Delivery
+          }
         }
       | {
           readonly id: Event.ID
@@ -814,15 +812,6 @@ export type Endpoint5_31Output =
             readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
           }
         }
-      | {
-          readonly id: Event.ID
-          readonly created: DateTime.Utc
-          readonly metadata?: { readonly [x: string]: unknown } | undefined
-          readonly type: "session.compaction.admitted"
-          readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
-          readonly location?: Location.Ref | undefined
-          readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
-        }
       | {
           readonly id: Event.ID
           readonly created: DateTime.Utc
@@ -953,11 +942,11 @@ export interface SessionApi<E = never> {
     readonly commit: SessionRevertCommitOperation<E>
   }
   readonly context: SessionContextOperation<E>
-  readonly pending: {
-    readonly list: SessionPendingListOperation<E>
-    readonly cancel: SessionPendingCancelOperation<E>
-    readonly steer: SessionPendingSteerOperation<E>
-    readonly queue: SessionPendingQueueOperation<E>
+  readonly inbox: {
+    readonly list: SessionInboxListOperation<E>
+    readonly cancel: SessionInboxCancelOperation<E>
+    readonly steer: SessionInboxSteerOperation<E>
+    readonly queue: SessionInboxQueueOperation<E>
   }
   readonly instructions: {
     readonly entry: {

+ 10 - 7
packages/client/src/effect/generated/client.ts

@@ -399,7 +399,7 @@ const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11I
   preserveEffect<Endpoint5_11Output>()(
     raw["session.move"]({
       params: { sessionID: input["sessionID"] },
-      payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
+      payload: { directory: input["directory"], workspaceID: input["workspaceID"], delivery: input["delivery"] },
     }).pipe(Effect.mapError(mapClientError)),
   )
 
@@ -481,7 +481,10 @@ const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16I
 
 const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
   preserveEffect<Endpoint5_17Output>()(
-    raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
+    raw["session.compact"]({
+      params: { sessionID: input["sessionID"] },
+      payload: { id: input["id"], delivery: input["delivery"] },
+    }).pipe(
       Effect.mapError(mapClientError),
       Effect.map((value) => value.data),
     ),
@@ -523,7 +526,7 @@ const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22I
 
 const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
   preserveEffect<Endpoint5_23Output>()(
-    raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
+    raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
       Effect.mapError(mapClientError),
       Effect.map((value) => value.data),
     ),
@@ -531,21 +534,21 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
 
 const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
   preserveEffect<Endpoint5_24Output>()(
-    raw["session.pending.cancel"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
+    raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
       Effect.mapError(mapClientError),
     ),
   )
 
 const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
   preserveEffect<Endpoint5_25Output>()(
-    raw["session.pending.steer"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
+    raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
       Effect.mapError(mapClientError),
     ),
   )
 
 const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
   preserveEffect<Endpoint5_26Output>()(
-    raw["session.pending.queue"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
+    raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
       Effect.mapError(mapClientError),
     ),
   )
@@ -637,7 +640,7 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
   wait: Endpoint5_18(raw),
   revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
   context: Endpoint5_22(raw),
-  pending: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
+  inbox: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
   instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } },
   generate: Endpoint5_30(raw),
   log: Endpoint5_31(raw),

+ 1 - 1
packages/client/src/effect/index.ts

@@ -41,7 +41,7 @@ export { Reference } from "@opencode-ai/schema/reference"
 export { WebSearch } from "@opencode-ai/schema/websearch"
 export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
 export { Session } from "@opencode-ai/schema/session"
-export { SessionPending } from "@opencode-ai/schema/session-pending"
+export { SessionInbox } from "@opencode-ai/schema/session-inbox"
 export { SessionMessage } from "@opencode-ai/schema/session-message"
 export { Skill } from "@opencode-ai/schema/skill"
 export { Prompt } from "@opencode-ai/schema/prompt"

+ 23 - 23
packages/client/src/promise/generated/client.ts

@@ -56,14 +56,14 @@ import type {
   SessionRevertCommitOutput,
   SessionContextInput,
   SessionContextOutput,
-  SessionPendingListInput,
-  SessionPendingListOutput,
-  SessionPendingCancelInput,
-  SessionPendingCancelOutput,
-  SessionPendingSteerInput,
-  SessionPendingSteerOutput,
-  SessionPendingQueueInput,
-  SessionPendingQueueOutput,
+  SessionInboxListInput,
+  SessionInboxListOutput,
+  SessionInboxCancelInput,
+  SessionInboxCancelOutput,
+  SessionInboxSteerInput,
+  SessionInboxSteerOutput,
+  SessionInboxQueueInput,
+  SessionInboxQueueOutput,
   SessionInstructionsEntryListInput,
   SessionInstructionsEntryListOutput,
   SessionInstructionsEntryPutInput,
@@ -598,7 +598,7 @@ export function make(options: ClientOptions) {
           {
             method: "POST",
             path: `/api/session/${encodeURIComponent(input.sessionID)}/move`,
-            body: { directory: input["directory"], workspaceID: input["workspaceID"] },
+            body: { directory: input["directory"], workspaceID: input["workspaceID"], delivery: input["delivery"] },
             successStatus: 204,
             declaredStatuses: [404, 400, 401],
             empty: true,
@@ -697,7 +697,7 @@ export function make(options: ClientOptions) {
           {
             method: "POST",
             path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`,
-            body: { id: input["id"] },
+            body: { id: input["id"], delivery: input["delivery"] },
             successStatus: 200,
             declaredStatuses: [409, 404, 400, 401],
             empty: false,
@@ -762,45 +762,45 @@ export function make(options: ClientOptions) {
           },
           requestOptions,
         ).then((value) => value.data),
-      pending: {
-        list: (input: SessionPendingListInput, requestOptions?: RequestOptions) =>
-          request<{ readonly data: SessionPendingListOutput }>(
+      inbox: {
+        list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
+          request<{ readonly data: SessionInboxListOutput }>(
             {
               method: "GET",
-              path: `/api/session/${encodeURIComponent(input.sessionID)}/pending`,
+              path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox`,
               successStatus: 200,
               declaredStatuses: [404, 401, 400],
               empty: false,
             },
             requestOptions,
           ).then((value) => value.data),
-        cancel: (input: SessionPendingCancelInput, requestOptions?: RequestOptions) =>
-          request<SessionPendingCancelOutput>(
+        cancel: (input: SessionInboxCancelInput, requestOptions?: RequestOptions) =>
+          request<SessionInboxCancelOutput>(
             {
               method: "DELETE",
-              path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}`,
+              path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}`,
               successStatus: 204,
               declaredStatuses: [409, 404, 401, 400],
               empty: true,
             },
             requestOptions,
           ),
-        steer: (input: SessionPendingSteerInput, requestOptions?: RequestOptions) =>
-          request<SessionPendingSteerOutput>(
+        steer: (input: SessionInboxSteerInput, requestOptions?: RequestOptions) =>
+          request<SessionInboxSteerOutput>(
             {
               method: "POST",
-              path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/steer`,
+              path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}/steer`,
               successStatus: 204,
               declaredStatuses: [409, 404, 401, 400],
               empty: true,
             },
             requestOptions,
           ),
-        queue: (input: SessionPendingQueueInput, requestOptions?: RequestOptions) =>
-          request<SessionPendingQueueOutput>(
+        queue: (input: SessionInboxQueueInput, requestOptions?: RequestOptions) =>
+          request<SessionInboxQueueOutput>(
             {
               method: "POST",
-              path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/queue`,
+              path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}/queue`,
               successStatus: 204,
               declaredStatuses: [409, 404, 401, 400],
               empty: true,

+ 164 - 153
packages/client/src/promise/generated/types.ts

@@ -130,15 +130,17 @@ export type SessionMessageCompactionCompleted = {
 
 export type SessionActive = { type: "running" }
 
-export type SessionPendingSyntheticData = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
+export type SessionInboxDelivery = "steer" | "queue"
 
-export type SessionPendingCompaction = { id: string; sessionID: string; timeCreated: number; type: "compaction" }
+export type SessionInboxSyntheticPayload = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
+
+export type SessionInboxCompactionPayload = {}
 
 export type InstructionEntryKey = string
 
 export type SessionGenerateResponse = { data: { text: string } }
 
-export type SessionPendingSyntheticData1 = { text: string; description?: string; metadata?: { [x: string]: any } }
+export type SessionInboxSyntheticPayload1 = { text: string; description?: string; metadata?: { [x: string]: any } }
 
 export type ShellInfo = {
   id: string
@@ -420,6 +422,8 @@ export type SessionMessageLocationSwitched = {
   previous?: { location: LocationRef; projectID?: string; subpath?: string }
 }
 
+export type SessionInboxMovePayload = { location: LocationRef; projectID: string; subpath?: string }
+
 export type SessionCreated = {
   id: string
   created: number
@@ -468,7 +472,7 @@ export type SessionMoved = {
   type: "session.moved"
   durable: { aggregateID: string; seq: number; version: 1 }
   location?: LocationRef
-  data: { sessionID: string; location: LocationRef; projectID?: string; subpath?: string }
+  data: { sessionID: string; location: LocationRef; projectID: string; subpath?: string }
 }
 
 export type SessionRenamed = {
@@ -501,44 +505,24 @@ export type SessionForked = {
   data: { sessionID: string; parentID: string; boundary: SessionForkBoundary; instructions?: { [x: string]: string } }
 }
 
-export type SessionInputPromoted = {
-  id: string
-  created: number
-  metadata?: { [x: string]: any }
-  type: "session.input.promoted"
-  durable: { aggregateID: string; seq: number; version: 1 }
-  location?: LocationRef
-  data: { sessionID: string; inputID: string }
-}
-
-export type SessionInputCancelled = {
-  id: string
-  created: number
-  metadata?: { [x: string]: any }
-  type: "session.input.cancelled"
-  durable: { aggregateID: string; seq: number; version: 1 }
-  location?: LocationRef
-  data: { sessionID: string; inputID: string }
-}
-
-export type SessionInputSteered = {
+export type SessionInboxDelivered = {
   id: string
   created: number
   metadata?: { [x: string]: any }
-  type: "session.input.steered"
+  type: "session.inbox.delivered"
   durable: { aggregateID: string; seq: number; version: 1 }
   location?: LocationRef
-  data: { sessionID: string; inputID: string }
+  data: { sessionID: string; inboxID: string }
 }
 
-export type SessionInputQueued = {
+export type SessionInboxCancelled = {
   id: string
   created: number
   metadata?: { [x: string]: any }
-  type: "session.input.queued"
+  type: "session.inbox.cancelled"
   durable: { aggregateID: string; seq: number; version: 1 }
   location?: LocationRef
-  data: { sessionID: string; inputID: string }
+  data: { sessionID: string; inboxID: string }
 }
 
 export type SessionExecutionStarted = {
@@ -659,16 +643,6 @@ export type SessionToolInputEnded = {
   data: { sessionID: string; assistantMessageID: string; id: string; text: string }
 }
 
-export type SessionCompactionAdmitted = {
-  id: string
-  created: number
-  metadata?: { [x: string]: any }
-  type: "session.compaction.admitted"
-  durable: { aggregateID: string; seq: number; version: 1 }
-  location?: LocationRef
-  data: { sessionID: string; inputID: string }
-}
-
 export type SessionCompactionStarted = {
   id: string
   created: number
@@ -1162,23 +1136,36 @@ export type SessionCompactionFailed = {
   data: { sessionID: string; reason: "auto" | "manual"; error: SessionStructuredError; inputID?: string }
 }
 
-export type SessionPendingSynthetic = {
+export type SessionInboxDeliveryChanged = {
+  id: string
+  created: number
+  metadata?: { [x: string]: any }
+  type: "session.inbox.delivery.changed"
+  durable: { aggregateID: string; seq: number; version: 1 }
+  location?: LocationRef
+  data: { sessionID: string; inboxID: string; delivery: SessionInboxDelivery }
+}
+
+export type SessionInboxSynthetic = {
   id: string
   sessionID: string
   timeCreated: number
   type: "synthetic"
-  data: SessionPendingSyntheticData
-  delivery: "steer" | "queue"
+  payload: SessionInboxSyntheticPayload
+  delivery: SessionInboxDelivery
 }
 
-export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue }
-
-export type SessionPendingSyntheticMessage = {
-  type: "synthetic"
-  data: SessionPendingSyntheticData1
-  delivery: "steer" | "queue"
+export type SessionInboxCompaction = {
+  id: string
+  sessionID: string
+  timeCreated: number
+  type: "compaction"
+  payload: SessionInboxCompactionPayload
+  delivery: SessionInboxDelivery
 }
 
+export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue }
+
 export type SessionShellStarted = {
   id: string
   created: number
@@ -1527,6 +1514,15 @@ export type VcsInfo = { branch: VcsBranch }
 
 export type PermissionRuleset = Array<PermissionRule>
 
+export type SessionInboxMove = {
+  id: string
+  sessionID: string
+  timeCreated: number
+  type: "move"
+  payload: SessionInboxMovePayload
+  delivery: SessionInboxDelivery
+}
+
 export type SessionInfo = {
   id: string
   parentID?: string
@@ -1564,7 +1560,7 @@ export type SessionMessageUser = {
   type: "user"
 }
 
-export type SessionPendingUserData = {
+export type SessionInboxUserPayload = {
   text: string
   files?: Array<PromptFileAttachment>
   agents?: Array<PromptAgentAttachment>
@@ -1572,7 +1568,7 @@ export type SessionPendingUserData = {
   metadata?: { [x: string]: JsonValue }
 }
 
-export type SessionPendingUserData1 = {
+export type SessionInboxUserPayload1 = {
   text: string
   files?: Array<PromptFileAttachment>
   agents?: Array<PromptAgentAttachment>
@@ -1884,16 +1880,20 @@ export type ConfigEntry =
 
 export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
 
-export type SessionPendingUser = {
+export type SessionInboxUser = {
   id: string
   sessionID: string
   timeCreated: number
   type: "user"
-  data: SessionPendingUserData
-  delivery: "steer" | "queue"
+  payload: SessionInboxUserPayload
+  delivery: SessionInboxDelivery
 }
 
-export type SessionPendingUserMessage = { type: "user"; data: SessionPendingUserData1; delivery: "steer" | "queue" }
+export type SessionInboxItem =
+  | { type: "user"; payload: SessionInboxUserPayload1; delivery: SessionInboxDelivery }
+  | { type: "synthetic"; payload: SessionInboxSyntheticPayload1; delivery: SessionInboxDelivery }
+  | { type: "compaction"; payload: SessionInboxCompactionPayload; delivery: SessionInboxDelivery }
+  | { type: "move"; payload: SessionInboxMovePayload; delivery: SessionInboxDelivery }
 
 export type SessionMessageAssistantTool = {
   type: "tool"
@@ -1914,9 +1914,17 @@ export type FormFields = [FormField, ...Array<FormField>]
 
 export type FormFields3 = [FormField1, ...Array<FormField1>]
 
-export type SessionPendingInfo = SessionPendingUser | SessionPendingSynthetic | SessionPendingCompaction
+export type SessionInboxInfo = SessionInboxUser | SessionInboxSynthetic | SessionInboxCompaction | SessionInboxMove
 
-export type SessionPendingMessage = SessionPendingUserMessage | SessionPendingSyntheticMessage
+export type SessionInboxEnqueued = {
+  id: string
+  created: number
+  metadata?: { [x: string]: any }
+  type: "session.inbox.enqueued"
+  durable: { aggregateID: string; seq: number; version: 1 }
+  location?: LocationRef
+  data: { sessionID: string; inboxID: string; item: SessionInboxItem }
+}
 
 export type SessionMessageAssistant = {
   id: string
@@ -1942,43 +1950,6 @@ export type FormInfo = { id: string; sessionID: string; title: string; metadata?
 
 export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields3 }
 
-export type SessionInputAdmitted = {
-  id: string
-  created: number
-  metadata?: { [x: string]: any }
-  type: "session.input.admitted"
-  durable: { aggregateID: string; seq: number; version: 1 }
-  location?: LocationRef
-  data: { sessionID: string; inputID: string; input: SessionPendingMessage }
-}
-
-export type SessionMessageInfo =
-  | SessionMessageAgentSelected
-  | SessionMessageModelSelected
-  | SessionMessageLocationSwitched
-  | SessionMessageUser
-  | SessionMessageSynthetic
-  | SessionMessageSystem
-  | SessionMessageSkill
-  | SessionMessageShell
-  | SessionMessageAssistant
-  | SessionMessageCompaction
-
-export type IntegrationMethod =
-  | IntegrationOAuthMethod
-  | IntegrationCommandMethod
-  | IntegrationKeyMethod
-  | IntegrationEnvMethod
-
-export type FormCreated = {
-  id: string
-  created: number
-  metadata?: { [x: string]: any }
-  type: "form.created"
-  location?: LocationRef
-  data: { form: FormInfo1 }
-}
-
 export type SessionEventDurable =
   | SessionCreated
   | SessionAgentSelected
@@ -1987,11 +1958,10 @@ export type SessionEventDurable =
   | SessionRenamed
   | SessionDeleted
   | SessionForked
-  | SessionInputPromoted
-  | SessionInputAdmitted
-  | SessionInputCancelled
-  | SessionInputSteered
-  | SessionInputQueued
+  | SessionInboxDelivered
+  | SessionInboxEnqueued
+  | SessionInboxCancelled
+  | SessionInboxDeliveryChanged
   | SessionExecutionStarted
   | SessionExecutionSucceeded
   | SessionExecutionFailed
@@ -2014,7 +1984,6 @@ export type SessionEventDurable =
   | SessionToolSuccess
   | SessionToolFailed
   | SessionRetryScheduled
-  | SessionCompactionAdmitted
   | SessionCompactionStarted
   | SessionCompactionEnded
   | SessionCompactionFailed
@@ -2023,6 +1992,35 @@ export type SessionEventDurable =
   | SessionRevertCommitted
   | SessionUsageRecorded
 
+export type SessionMessageInfo =
+  | SessionMessageAgentSelected
+  | SessionMessageModelSelected
+  | SessionMessageLocationSwitched
+  | SessionMessageUser
+  | SessionMessageSynthetic
+  | SessionMessageSystem
+  | SessionMessageSkill
+  | SessionMessageShell
+  | SessionMessageAssistant
+  | SessionMessageCompaction
+
+export type IntegrationMethod =
+  | IntegrationOAuthMethod
+  | IntegrationCommandMethod
+  | IntegrationKeyMethod
+  | IntegrationEnvMethod
+
+export type FormCreated = {
+  id: string
+  created: number
+  metadata?: { [x: string]: any }
+  type: "form.created"
+  location?: LocationRef
+  data: { form: FormInfo1 }
+}
+
+export type SessionLogItem = SessionEventDurable | EventLogSynced
+
 export type SessionTransferData = { info: SessionInfo; messages: Array<SessionMessageInfo> }
 
 export type SessionMessagesResponse = {
@@ -2051,11 +2049,10 @@ export type V2Event =
   | SessionUsageUpdated
   | SessionDeleted
   | SessionForked
-  | SessionInputPromoted
-  | SessionInputAdmitted
-  | SessionInputCancelled
-  | SessionInputSteered
-  | SessionInputQueued
+  | SessionInboxDelivered
+  | SessionInboxEnqueued
+  | SessionInboxCancelled
+  | SessionInboxDeliveryChanged
   | SessionExecutionStarted
   | SessionExecutionSucceeded
   | SessionExecutionFailed
@@ -2082,7 +2079,6 @@ export type V2Event =
   | SessionToolSuccess
   | SessionToolFailed
   | SessionRetryScheduled
-  | SessionCompactionAdmitted
   | SessionCompactionStarted
   | SessionCompactionDelta
   | SessionCompactionEnded
@@ -2127,8 +2123,6 @@ export type V2Event =
   | McpResourcesChanged
   | V2EventServerConnected
 
-export type SessionLogItem = SessionEventDurable | EventLogSynced
-
 export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
 export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
   typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
@@ -3361,8 +3355,21 @@ export type SessionRenameOutput = void
 
 export type SessionMoveInput = {
   readonly sessionID: { readonly sessionID: string }["sessionID"]
-  readonly directory: { readonly directory: string; readonly workspaceID?: string }["directory"]
-  readonly workspaceID?: { readonly directory: string; readonly workspaceID?: string }["workspaceID"]
+  readonly directory: {
+    readonly directory: string
+    readonly workspaceID?: string
+    readonly delivery?: ("steer" | "queue") | null
+  }["directory"]
+  readonly workspaceID?: {
+    readonly directory: string
+    readonly workspaceID?: string
+    readonly delivery?: ("steer" | "queue") | null
+  }["workspaceID"]
+  readonly delivery?: {
+    readonly directory: string
+    readonly workspaceID?: string
+    readonly delivery?: ("steer" | "queue") | null
+  }["delivery"]
 }
 
 export type SessionMoveOutput = void
@@ -3387,7 +3394,7 @@ export type SessionPromptInput = {
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
     readonly metadata?: { readonly [x: string]: JsonValue }
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["id"]
   readonly text: {
@@ -3408,7 +3415,7 @@ export type SessionPromptInput = {
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
     readonly metadata?: { readonly [x: string]: JsonValue }
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["text"]
   readonly files?: {
@@ -3429,7 +3436,7 @@ export type SessionPromptInput = {
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
     readonly metadata?: { readonly [x: string]: JsonValue }
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["files"]
   readonly agents?: {
@@ -3450,7 +3457,7 @@ export type SessionPromptInput = {
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
     readonly metadata?: { readonly [x: string]: JsonValue }
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["agents"]
   readonly skills?: {
@@ -3471,7 +3478,7 @@ export type SessionPromptInput = {
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
     readonly metadata?: { readonly [x: string]: JsonValue }
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["skills"]
   readonly metadata?: {
@@ -3492,7 +3499,7 @@ export type SessionPromptInput = {
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
     readonly metadata?: { readonly [x: string]: JsonValue }
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["metadata"]
   readonly delivery?: {
@@ -3513,7 +3520,7 @@ export type SessionPromptInput = {
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
     readonly metadata?: { readonly [x: string]: JsonValue }
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["delivery"]
   readonly resume?: {
@@ -3534,12 +3541,12 @@ export type SessionPromptInput = {
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
     readonly metadata?: { readonly [x: string]: JsonValue }
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["resume"]
 }
 
-export type SessionPromptOutput = { data: SessionPendingUser }["data"]
+export type SessionPromptOutput = { data: SessionInboxUser }["data"]
 
 export type SessionCommandInput = {
   readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -3563,7 +3570,7 @@ export type SessionCommandInput = {
       readonly id: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["id"]
   readonly command: {
@@ -3586,7 +3593,7 @@ export type SessionCommandInput = {
       readonly id: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["command"]
   readonly arguments?: {
@@ -3609,7 +3616,7 @@ export type SessionCommandInput = {
       readonly id: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["arguments"]
   readonly agent?: {
@@ -3632,7 +3639,7 @@ export type SessionCommandInput = {
       readonly id: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["agent"]
   readonly model?: {
@@ -3655,7 +3662,7 @@ export type SessionCommandInput = {
       readonly id: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["model"]
   readonly files?: {
@@ -3678,7 +3685,7 @@ export type SessionCommandInput = {
       readonly id: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["files"]
   readonly agents?: {
@@ -3701,7 +3708,7 @@ export type SessionCommandInput = {
       readonly id: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["agents"]
   readonly skills?: {
@@ -3724,7 +3731,7 @@ export type SessionCommandInput = {
       readonly id: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["skills"]
   readonly delivery?: {
@@ -3747,7 +3754,7 @@ export type SessionCommandInput = {
       readonly id: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["delivery"]
   readonly resume?: {
@@ -3770,12 +3777,12 @@ export type SessionCommandInput = {
       readonly id: string
       readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
     }>
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["resume"]
 }
 
-export type SessionCommandOutput = { data: SessionPendingUser }["data"]
+export type SessionCommandOutput = { data: SessionInboxUser }["data"]
 
 export type SessionSkillInput = {
   readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -3805,7 +3812,7 @@ export type SessionSyntheticInput = {
     readonly text: string
     readonly description?: string | null
     readonly metadata?: { readonly [x: string]: JsonValue }
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["id"]
   readonly text: {
@@ -3813,7 +3820,7 @@ export type SessionSyntheticInput = {
     readonly text: string
     readonly description?: string | null
     readonly metadata?: { readonly [x: string]: JsonValue }
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["text"]
   readonly description?: {
@@ -3821,7 +3828,7 @@ export type SessionSyntheticInput = {
     readonly text: string
     readonly description?: string | null
     readonly metadata?: { readonly [x: string]: JsonValue }
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["description"]
   readonly metadata?: {
@@ -3829,7 +3836,7 @@ export type SessionSyntheticInput = {
     readonly text: string
     readonly description?: string | null
     readonly metadata?: { readonly [x: string]: JsonValue }
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["metadata"]
   readonly delivery?: {
@@ -3837,7 +3844,7 @@ export type SessionSyntheticInput = {
     readonly text: string
     readonly description?: string | null
     readonly metadata?: { readonly [x: string]: JsonValue }
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["delivery"]
   readonly resume?: {
@@ -3845,12 +3852,12 @@ export type SessionSyntheticInput = {
     readonly text: string
     readonly description?: string | null
     readonly metadata?: { readonly [x: string]: JsonValue }
-    readonly delivery?: "steer" | "queue" | null
+    readonly delivery?: ("steer" | "queue") | null
     readonly resume?: boolean | null
   }["resume"]
 }
 
-export type SessionSyntheticOutput = { data: SessionPendingSynthetic }["data"]
+export type SessionSyntheticOutput = { data: SessionInboxSynthetic }["data"]
 
 export type SessionShellInput = {
   readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -3862,10 +3869,14 @@ export type SessionShellOutput = void
 
 export type SessionCompactInput = {
   readonly sessionID: { readonly sessionID: string }["sessionID"]
-  readonly id?: { readonly id?: string | undefined }["id"]
+  readonly id?: { readonly id?: string | undefined; readonly delivery?: ("steer" | "queue") | undefined }["id"]
+  readonly delivery?: {
+    readonly id?: string | undefined
+    readonly delivery?: ("steer" | "queue") | undefined
+  }["delivery"]
 }
 
-export type SessionCompactOutput = { data: SessionPendingCompaction }["data"]
+export type SessionCompactOutput = { data: SessionInboxCompaction }["data"]
 
 export type SessionWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
 
@@ -3891,30 +3902,30 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
 
 export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
 
-export type SessionPendingListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
+export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
 
-export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"]
+export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
 
-export type SessionPendingCancelInput = {
-  readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
-  readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
+export type SessionInboxCancelInput = {
+  readonly sessionID: { readonly sessionID: string; readonly inboxID: string }["sessionID"]
+  readonly inboxID: { readonly sessionID: string; readonly inboxID: string }["inboxID"]
 }
 
-export type SessionPendingCancelOutput = void
+export type SessionInboxCancelOutput = void
 
-export type SessionPendingSteerInput = {
-  readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
-  readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
+export type SessionInboxSteerInput = {
+  readonly sessionID: { readonly sessionID: string; readonly inboxID: string }["sessionID"]
+  readonly inboxID: { readonly sessionID: string; readonly inboxID: string }["inboxID"]
 }
 
-export type SessionPendingSteerOutput = void
+export type SessionInboxSteerOutput = void
 
-export type SessionPendingQueueInput = {
-  readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
-  readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
+export type SessionInboxQueueInput = {
+  readonly sessionID: { readonly sessionID: string; readonly inboxID: string }["sessionID"]
+  readonly inboxID: { readonly sessionID: string; readonly inboxID: string }["inboxID"]
 }
 
-export type SessionPendingQueueOutput = void
+export type SessionInboxQueueOutput = void
 
 export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
 

+ 4 - 2
packages/client/test/effect.test.ts

@@ -213,7 +213,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
   expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
   expect(result.created.id).toBe("ses_test")
   expect(Object.getPrototypeOf(result.admitted)).toBe(Object.prototype)
-  expect(Object.getPrototypeOf(result.admitted.data)).toBe(Object.prototype)
+  expect(Object.getPrototypeOf(result.admitted.payload)).toBe(Object.prototype)
   expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
   expect(result.context).toEqual([])
   expect(logQueries[0]).toEqual({ after: "0" })
@@ -271,7 +271,7 @@ const admission = {
     id: "msg_test",
     sessionID: "ses_test",
     type: "user",
-    data: { text: "Hello" },
+    payload: { text: "Hello" },
     delivery: "steer",
     timeCreated: 1_717_171_717_000,
   },
@@ -280,6 +280,8 @@ const admission = {
 const compactionAdmission = {
   data: {
     type: "compaction",
+    payload: {},
+    delivery: "queue",
     id: "msg_compaction",
     sessionID: "ses_test",
     timeCreated: 1_717_171_717_000,

+ 11 - 11
packages/client/test/promise.test.ts

@@ -373,7 +373,7 @@ test("session instructions methods use the public HTTP contract", async () => {
   ])
 })
 
-test("session.pending.list uses the public HTTP contract", async () => {
+test("session.inbox.list uses the public HTTP contract", async () => {
   const requests: Array<{ method: string; url: string }> = []
   const pending = [
     {
@@ -381,7 +381,7 @@ test("session.pending.list uses the public HTTP contract", async () => {
       sessionID: "ses_test",
       timeCreated: 1_717_171_717_000,
       type: "user",
-      data: { text: "Fix the failing tests" },
+      payload: { text: "Fix the failing tests" },
       delivery: "steer",
     },
   ]
@@ -394,13 +394,13 @@ test("session.pending.list uses the public HTTP contract", async () => {
     },
   })
 
-  const result = await client.session.pending.list({ sessionID: "ses_test" })
+  const result = await client.session.inbox.list({ sessionID: "ses_test" })
 
   expect(result).toEqual(pending)
-  expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
+  expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/inbox" }])
 })
 
-test("session.pending mutations use the public HTTP contract", async () => {
+test("session.inbox mutations use the public HTTP contract", async () => {
   const requests: Array<{ method: string; url: string }> = []
   const client = OpenCode.make({
     baseUrl: "http://localhost:3000",
@@ -411,14 +411,14 @@ test("session.pending mutations use the public HTTP contract", async () => {
     },
   })
 
-  await client.session.pending.cancel({ sessionID: "ses_test", inputID: "msg_cancel" })
-  await client.session.pending.steer({ sessionID: "ses_test", inputID: "msg_steer" })
-  await client.session.pending.queue({ sessionID: "ses_test", inputID: "msg_queue" })
+  await client.session.inbox.cancel({ sessionID: "ses_test", inboxID: "msg_cancel" })
+  await client.session.inbox.steer({ sessionID: "ses_test", inboxID: "msg_steer" })
+  await client.session.inbox.queue({ sessionID: "ses_test", inboxID: "msg_queue" })
 
   expect(requests).toEqual([
-    { method: "DELETE", url: "http://localhost:3000/api/session/ses_test/pending/msg_cancel" },
-    { method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_steer/steer" },
-    { method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_queue/queue" },
+    { method: "DELETE", url: "http://localhost:3000/api/session/ses_test/inbox/msg_cancel" },
+    { method: "POST", url: "http://localhost:3000/api/session/ses_test/inbox/msg_steer/steer" },
+    { method: "POST", url: "http://localhost:3000/api/session/ses_test/inbox/msg_queue/queue" },
   ])
 })
 

+ 251 - 38
packages/core/schema.json

@@ -1,8 +1,10 @@
 {
   "version": "7",
   "dialect": "sqlite",
-  "id": "00924d88-1842-4d71-ac74-5682ddc47e1c",
-  "prevIds": ["15060ec5-05f7-4b86-b2a5-9108609432b3"],
+  "id": "5c1aa56b-c3ee-4283-9a84-c0bf626dc604",
+  "prevIds": [
+    "00924d88-1842-4d71-ac74-5682ddc47e1c"
+  ],
   "ddl": [
     {
       "name": "account_state",
@@ -56,6 +58,10 @@
       "name": "instruction_state",
       "entityType": "tables"
     },
+    {
+      "name": "session_inbox",
+      "entityType": "tables"
+    },
     {
       "name": "session_message",
       "entityType": "tables"
@@ -842,6 +848,76 @@
       "entityType": "columns",
       "table": "instruction_state"
     },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session_inbox"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "session_inbox"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "session_inbox"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "payload",
+      "entityType": "columns",
+      "table": "session_inbox"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "delivery",
+      "entityType": "columns",
+      "table": "session_inbox"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "enqueued_seq",
+      "entityType": "columns",
+      "table": "session_inbox"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session_inbox"
+    },
     {
       "type": "text",
       "notNull": false,
@@ -1363,9 +1439,13 @@
       "table": "workspace"
     },
     {
-      "columns": ["active_account_id"],
+      "columns": [
+        "active_account_id"
+      ],
       "tableTo": "account",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "SET NULL",
       "nameExplicit": false,
@@ -1374,9 +1454,13 @@
       "table": "account_state"
     },
     {
-      "columns": ["aggregate_id"],
+      "columns": [
+        "aggregate_id"
+      ],
       "tableTo": "event_sequence",
-      "columnsTo": ["aggregate_id"],
+      "columnsTo": [
+        "aggregate_id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1385,9 +1469,13 @@
       "table": "event"
     },
     {
-      "columns": ["project_id"],
+      "columns": [
+        "project_id"
+      ],
       "tableTo": "project",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1396,9 +1484,13 @@
       "table": "permission"
     },
     {
-      "columns": ["project_id"],
+      "columns": [
+        "project_id"
+      ],
       "tableTo": "project",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1407,9 +1499,13 @@
       "table": "project_directory"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "tableTo": "session_v2",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1418,9 +1514,13 @@
       "table": "instruction_entry"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "tableTo": "session_v2",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1429,9 +1529,28 @@
       "table": "instruction_state"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
+      "tableTo": "session_v2",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_inbox_session_id_session_v2_id_fk",
+      "entityType": "fks",
+      "table": "session_inbox"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
       "tableTo": "session_v2",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1440,9 +1559,13 @@
       "table": "session_message"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "tableTo": "session_v2",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1451,9 +1574,13 @@
       "table": "session_pending"
     },
     {
-      "columns": ["project_id"],
+      "columns": [
+        "project_id"
+      ],
       "tableTo": "project",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1462,119 +1589,165 @@
       "table": "session_v2"
     },
     {
-      "columns": ["email", "url"],
+      "columns": [
+        "email",
+        "url"
+      ],
       "nameExplicit": false,
       "name": "control_account_pk",
       "entityType": "pks",
       "table": "control_account"
     },
     {
-      "columns": ["project_id", "directory"],
+      "columns": [
+        "project_id",
+        "directory"
+      ],
       "nameExplicit": false,
       "name": "project_directory_pk",
       "entityType": "pks",
       "table": "project_directory"
     },
     {
-      "columns": ["session_id", "key"],
+      "columns": [
+        "session_id",
+        "key"
+      ],
       "nameExplicit": false,
       "name": "instruction_entry_pk",
       "entityType": "pks",
       "table": "instruction_entry"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "account_state_pk",
       "table": "account_state",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "account_pk",
       "table": "account",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "credential_pk",
       "table": "credential",
       "entityType": "pks"
     },
     {
-      "columns": ["aggregate_id"],
+      "columns": [
+        "aggregate_id"
+      ],
       "nameExplicit": false,
       "name": "event_sequence_pk",
       "table": "event_sequence",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "event_pk",
       "table": "event",
       "entityType": "pks"
     },
     {
-      "columns": ["key"],
+      "columns": [
+        "key"
+      ],
       "nameExplicit": false,
       "name": "kv_pk",
       "table": "kv",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "permission_pk",
       "table": "permission",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "project_pk",
       "table": "project",
       "entityType": "pks"
     },
     {
-      "columns": ["hash"],
+      "columns": [
+        "hash"
+      ],
       "nameExplicit": false,
       "name": "instruction_blob_pk",
       "table": "instruction_blob",
       "entityType": "pks"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "nameExplicit": false,
       "name": "instruction_state_pk",
       "table": "instruction_state",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "session_inbox_pk",
+      "table": "session_inbox",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "session_message_pk",
       "table": "session_message",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "session_pending_pk",
       "table": "session_pending",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "session_v2_pk",
       "table": "session_v2",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "workspace_pk",
       "table": "workspace",
@@ -1642,6 +1815,46 @@
       "entityType": "indexes",
       "table": "permission"
     },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        },
+        {
+          "value": "delivery",
+          "isExpression": false
+        },
+        {
+          "value": "enqueued_seq",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_inbox_session_delivery_seq_idx",
+      "entityType": "indexes",
+      "table": "session_inbox"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        },
+        {
+          "value": "enqueued_seq",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "where": null,
+      "origin": "manual",
+      "name": "session_inbox_session_enqueued_seq_idx",
+      "entityType": "indexes",
+      "table": "session_inbox"
+    },
     {
       "columns": [
         {
@@ -1830,4 +2043,4 @@
     }
   ],
   "renames": []
-}
+}

+ 196 - 30
packages/core/src/bus.ts

@@ -7,6 +7,7 @@ import { and, asc, eq, gt, lte, sql } from "drizzle-orm"
 import { Database } from "./database/database.js"
 import { EventSequenceTable, EventTable } from "./event/sql.js"
 import { Location } from "./location.js"
+import { KeyedMutex } from "./effect/keyed-mutex.js"
 import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
 import { isDeepStrictEqual } from "node:util"
 import { Durable } from "@opencode-ai/schema/durable-event-manifest"
@@ -92,6 +93,16 @@ export interface PublishOptions {
   readonly commit?: (seq: number) => Effect.Effect<void>
 }
 
+export type PublishInput<D extends Event.DurableDefinition = Event.DurableDefinition> = readonly [
+  definition: D,
+  data: Event.Data<D>,
+  options?: PublishOptions,
+]
+
+export type PublishResult<I extends readonly PublishInput[]> = {
+  readonly [K in keyof I]: I[K] extends PublishInput<infer D> ? Event.Payload<D> : never
+}
+
 /** Marker/event union emitted by `log`. */
 export type LogItem = Event.Payload | EventLog.Synced
 
@@ -124,6 +135,9 @@ export interface Interface {
     data: Event.Data<D>,
     options?: PublishOptions,
   ) => Effect.Effect<Event.Payload<D>>
+  readonly publishAll: <const I extends readonly [PublishInput, ...PublishInput[]]>(
+    events: I,
+  ) => Effect.Effect<PublishResult<I>>
   readonly subscribe: Subscribe
   /**
    * Durable, ordered per-aggregate log read. Forked aggregates may reserve an
@@ -176,6 +190,7 @@ export function configured(options?: Options) {
         }
         const projectors = new Map<string, Subscriber[]>()
         const listeners = new Array<Subscriber>()
+        const durableLocks = KeyedMutex.makeUnsafe<string>()
         const { db } = yield* Database.Service
         const logReadPageSize = options?.logReadPageSize ?? 512
         const persist = options?.persist ?? false
@@ -385,15 +400,23 @@ export function configured(options?: Options) {
                 }),
               )
             if (definition?.durable) {
-              const committed = yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit)
-              if (committed) {
-                event = {
-                  ...event,
-                  durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
-                }
-                yield* notify(event as Event.Payload, true)
-                return event
-              }
+              const aggregateID = (event.data as Record<string, unknown>)[definition.durable.aggregate]
+              if (typeof aggregateID !== "string")
+                return yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit).pipe(
+                  Effect.as(event),
+                )
+              return yield* durableLocks.withLock(aggregateID)(
+                Effect.gen(function* () {
+                  const committed = yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit)
+                  if (!committed) return event
+                  event = {
+                    ...event,
+                    durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
+                  }
+                  yield* notify(event as Event.Payload, true)
+                  return event
+                }),
+              )
             }
             yield* notify(event as Event.Payload, false)
             return event
@@ -444,6 +467,144 @@ export function configured(options?: Options) {
           })
         }
 
+        function publishAll<const I extends readonly [PublishInput, ...PublishInput[]]>(events: I) {
+          return Effect.gen(function* () {
+            const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
+            const payloads = yield* Effect.forEach(events, ([definition, data, options]) =>
+              Effect.gen(function* () {
+                const aggregateID = (data as Record<string, unknown>)[definition.durable.aggregate]
+                if (typeof aggregateID !== "string") {
+                  return yield* Effect.die(
+                    new InvalidDurableEventError({
+                      type: definition.type,
+                      message: `Expected string aggregate field ${definition.durable.aggregate}`,
+                    }),
+                  )
+                }
+                const location =
+                  options?.location ??
+                  (serviceLocation
+                    ? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
+                    : undefined)
+                return {
+                  definition,
+                  aggregateID,
+                  commit: options?.commit,
+                  event: {
+                    id: options?.id ?? Event.ID.create(),
+                    created: yield* DateTime.now,
+                    ...(options?.metadata ? { metadata: options.metadata } : {}),
+                    type: definition.type,
+                    ...(location ? { location } : {}),
+                    data,
+                  } as Event.Payload,
+                }
+              }),
+            )
+            const aggregateID = payloads[0].aggregateID
+            if (payloads.some((item) => item.aggregateID !== aggregateID)) {
+              return yield* Effect.die(
+                new InvalidDurableEventError({
+                  type: payloads[0].definition.type,
+                  message: "Published events must belong to the same aggregate",
+                }),
+              )
+            }
+            return yield* durableLocks.withLock(aggregateID)(
+              Effect.uninterruptible(
+                Effect.gen(function* () {
+                  const committed = yield* db
+                    .transaction(
+                      () =>
+                        Effect.gen(function* () {
+                          const row = yield* db
+                            .select({ seq: EventSequenceTable.seq })
+                            .from(EventSequenceTable)
+                            .where(eq(EventSequenceTable.aggregate_id, aggregateID))
+                            .get()
+                            .pipe(Effect.orDie)
+                          const firstSeq = (row?.seq ?? -1) + 1
+                          const finalSeq = firstSeq + payloads.length - 1
+                          const result = new Array<Event.Payload>()
+                          const rows = new Array<typeof EventTable.$inferInsert>()
+                          const ids = new Set<Event.ID>()
+                          for (const [index, item] of payloads.entries()) {
+                            const seq = firstSeq + index
+                            const encoded = Schema.encodeUnknownSync(item.definition.data)(item.event.data) as Record<
+                              string,
+                              unknown
+                            >
+                            if (persist) {
+                              if (ids.has(item.event.id))
+                                yield* Effect.die(
+                                  new InvalidDurableEventError({
+                                    type: item.event.type,
+                                    message: `Event ${item.event.id} appears more than once in the batch`,
+                                  }),
+                                )
+                              ids.add(item.event.id)
+                              const stored = yield* db
+                                .select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
+                                .from(EventTable)
+                                .where(eq(EventTable.id, item.event.id))
+                                .get()
+                                .pipe(Effect.orDie)
+                              if (stored)
+                                yield* Effect.die(
+                                  new InvalidDurableEventError({
+                                    type: item.event.type,
+                                    message: `Event ${item.event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
+                                  }),
+                                )
+                            }
+                            const event = {
+                              ...item.event,
+                              durable: envelope(aggregateID, seq, item.definition.durable.version),
+                            } as Event.Payload
+                            for (const projector of projectors.get(
+                              versionedType(item.definition.type, item.definition.durable.version),
+                            ) ?? []) {
+                              yield* projector(event)
+                            }
+                            if (item.commit) yield* item.commit(seq)
+                            if (persist)
+                              rows.push({
+                                id: event.id,
+                                aggregate_id: aggregateID,
+                                seq,
+                                created: DateTime.toEpochMillis(event.created),
+                                type: versionedType(item.definition.type, item.definition.durable.version),
+                                data: encoded,
+                              })
+                            result.push(event)
+                          }
+                          yield* db
+                            .insert(EventSequenceTable)
+                            .values([{ aggregate_id: aggregateID, seq: finalSeq }])
+                            .onConflictDoUpdate({ target: EventSequenceTable.aggregate_id, set: { seq: finalSeq } })
+                            .run()
+                            .pipe(Effect.orDie)
+                          if (persist) yield* db.insert(EventTable).values(rows).run().pipe(Effect.orDie)
+                          return result
+                        }),
+                      { behavior: "immediate" },
+                    )
+                    .pipe(Effect.orDie)
+                  yield* Effect.forEach(
+                    pubsub.durable.get(aggregateID) ?? [],
+                    (wake) => PubSub.publish(wake, undefined),
+                    {
+                      discard: true,
+                    },
+                  )
+                  yield* Effect.forEach(committed, (event) => notify(event, true), { discard: true })
+                  return committed as PublishResult<I>
+                }),
+              ),
+            )
+          })
+        }
+
         function replay(
           event: SerializedEvent,
           options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
@@ -455,27 +616,31 @@ export function configured(options?: Options) {
                 new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
               )
             } else {
-              const payload = {
-                id: event.id,
-                created: event.created ?? DateTime.makeUnsafe(0),
-                type: definition.type,
-                data: Schema.decodeUnknownSync(definition.data)(event.data),
-              } as Event.Payload
-              const committed = yield* commitDurableEvent(definition, payload, {
-                seq: event.seq,
-                aggregateID: event.aggregateID,
-                ownerID: options?.ownerID,
-                strictOwner: options?.strictOwner,
-              })
-              if (committed && options?.publish) {
-                yield* notify(
-                  {
-                    ...payload,
-                    durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
-                  },
-                  true,
-                )
-              }
+              yield* durableLocks.withLock(event.aggregateID)(
+                Effect.gen(function* () {
+                  const payload = {
+                    id: event.id,
+                    created: event.created ?? DateTime.makeUnsafe(0),
+                    type: definition.type,
+                    data: Schema.decodeUnknownSync(definition.data)(event.data),
+                  } as Event.Payload
+                  const committed = yield* commitDurableEvent(definition, payload, {
+                    seq: event.seq,
+                    aggregateID: event.aggregateID,
+                    ownerID: options?.ownerID,
+                    strictOwner: options?.strictOwner,
+                  })
+                  if (committed && options?.publish) {
+                    yield* notify(
+                      {
+                        ...payload,
+                        durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
+                      },
+                      true,
+                    )
+                  }
+                }),
+              )
             }
           })
         }
@@ -692,6 +857,7 @@ export function configured(options?: Options) {
 
         return Service.of({
           publish,
+          publishAll,
           subscribe,
           log,
           listen,

+ 2 - 0
packages/core/src/database/migration.gen.ts

@@ -41,6 +41,7 @@ import m38 from "./migration/20260804233008_loose_psylocke.js"
 import m39 from "./migration/20260805200742_import_legacy_credentials.js"
 import m40 from "./migration/20260808023530_workspace_domain.js"
 import m41 from "./migration/20260811161259_execution_claim_attempts.js"
+import m42 from "./migration/20260812181746_session_inbox.js"
 
 export const migrations = [
   m00,
@@ -85,4 +86,5 @@ export const migrations = [
   m39,
   m40,
   m41,
+  m42,
 ] satisfies DatabaseMigration.Migration[]

+ 30 - 0
packages/core/src/database/migration/20260812181746_session_inbox.ts

@@ -0,0 +1,30 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration.js"
+
+const migration: DatabaseMigration.Migration = {
+  id: "20260812181746_session_inbox",
+  up(tx) {
+    return Effect.gen(function* () {
+      yield* tx.run(`
+        CREATE TABLE \`session_inbox\` (
+          \`id\` text PRIMARY KEY,
+          \`session_id\` text NOT NULL,
+          \`type\` text NOT NULL,
+          \`payload\` text NOT NULL,
+          \`delivery\` text NOT NULL,
+          \`enqueued_seq\` integer NOT NULL,
+          \`time_created\` integer NOT NULL,
+          CONSTRAINT \`fk_session_inbox_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
+        );
+      `)
+      yield* tx.run(
+        `CREATE INDEX \`session_inbox_session_delivery_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`delivery\`,\`enqueued_seq\`);`,
+      )
+      yield* tx.run(
+        `CREATE UNIQUE INDEX \`session_inbox_session_enqueued_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`enqueued_seq\`);`,
+      )
+    })
+  },
+}
+
+export default migration

+ 18 - 0
packages/core/src/database/schema.gen.ts

@@ -142,6 +142,18 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
           CONSTRAINT \`fk_instruction_state_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
         );
       `)
+      yield* tx.run(`
+        CREATE TABLE \`session_inbox\` (
+          \`id\` text PRIMARY KEY,
+          \`session_id\` text NOT NULL,
+          \`type\` text NOT NULL,
+          \`payload\` text NOT NULL,
+          \`delivery\` text NOT NULL,
+          \`enqueued_seq\` integer NOT NULL,
+          \`time_created\` integer NOT NULL,
+          CONSTRAINT \`fk_session_inbox_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
+        );
+      `)
       yield* tx.run(`
         CREATE TABLE \`session_message\` (
           \`id\` text PRIMARY KEY,
@@ -218,6 +230,12 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
       yield* tx.run(
         `CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`,
       )
+      yield* tx.run(
+        `CREATE INDEX \`session_inbox_session_delivery_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`delivery\`,\`enqueued_seq\`);`,
+      )
+      yield* tx.run(
+        `CREATE UNIQUE INDEX \`session_inbox_session_enqueued_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`enqueued_seq\`);`,
+      )
       yield* tx.run(
         `CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
       )

+ 57 - 56
packages/core/src/session.ts

@@ -31,7 +31,7 @@ import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/err
 import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
 import { LocationServiceMap } from "./location-service-map.js"
 import { SessionEvent } from "./session/event.js"
-import { SessionPending } from "./session/pending.js"
+import { SessionInbox } from "./session/inbox.js"
 import { InstructionState } from "./session/instruction-state.js"
 import { SessionGenerate } from "./session/generate.js"
 import { Snapshot } from "./snapshot.js"
@@ -99,6 +99,7 @@ type CreateInput = CreateBaseInput &
 type CompactInput = {
   id?: SessionMessage.ID
   sessionID: SessionSchema.ID
+  delivery?: SessionInbox.Delivery
 }
 
 type ForkInput = {
@@ -133,14 +134,11 @@ export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionC
 export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
   sessionID: SessionSchema.ID,
 }) {}
-export class PendingInputConflictError extends Schema.TaggedErrorClass<PendingInputConflictError>()(
-  "Session.PendingInputConflictError",
-  {
-    sessionID: SessionSchema.ID,
-    inputID: SessionMessage.ID,
-  },
-) {}
-type PendingInputRef = { readonly sessionID: SessionSchema.ID; readonly inputID: SessionMessage.ID }
+export class InboxConflictError extends Schema.TaggedErrorClass<InboxConflictError>()("Session.InboxConflictError", {
+  sessionID: SessionSchema.ID,
+  inboxID: SessionMessage.ID,
+}) {}
+type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
 export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
   skill: Skill.ID,
 }) {}
@@ -188,10 +186,10 @@ export interface Interface {
    * ordered by admission. Includes unpromoted user and synthetic inputs and
    * unhandled compaction barriers.
    */
-  readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
-  readonly cancelPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
-  readonly steerPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
-  readonly queuePending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
+  readonly inbox: (sessionID: SessionSchema.ID) => Effect.Effect<SessionInbox.Info[], NotFoundError>
+  readonly cancelInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
+  readonly steerInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
+  readonly queueInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
   /**
    * Durable, ordered session log read. Replays durable session bus after
    * the exclusive `after` cursor, emits a `Synced` marker at the captured
@@ -211,6 +209,7 @@ export interface Interface {
     sessionID: SessionSchema.ID
     directory: AbsolutePath
     workspaceID?: Location.Ref["workspaceID"]
+    delivery?: SessionInbox.Delivery
   }) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError>
   readonly prompt: (input: {
     id?: SessionMessage.ID
@@ -220,9 +219,9 @@ export interface Interface {
     agents?: PromptInput.Prompt["agents"]
     skills?: PromptInput.Prompt["skills"]
     metadata?: Record<string, unknown>
-    delivery?: SessionPending.Delivery
+    delivery?: SessionInbox.Delivery
     resume?: boolean
-  }) => Effect.Effect<SessionPending.User, NotFoundError | PromptConflictError | AttachmentError | SkillNotFoundError>
+  }) => Effect.Effect<SessionInbox.User, NotFoundError | PromptConflictError | AttachmentError | SkillNotFoundError>
   /** Generates text from current Session context without admitting input or mutating history. */
   readonly generate: (input: {
     sessionID: SessionSchema.ID
@@ -238,10 +237,10 @@ export interface Interface {
     files?: PromptInput.Prompt["files"]
     agents?: PromptInput.Prompt["agents"]
     skills?: PromptInput.Prompt["skills"]
-    delivery?: SessionPending.Delivery
+    delivery?: SessionInbox.Delivery
     resume?: boolean
   }) => Effect.Effect<
-    SessionPending.User,
+    SessionInbox.User,
     | NotFoundError
     | PromptConflictError
     | AttachmentError
@@ -262,7 +261,7 @@ export interface Interface {
   }) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
   readonly compact: (
     input: CompactInput,
-  ) => Effect.Effect<SessionPending.Compaction, NotFoundError | CompactionConflictError>
+  ) => Effect.Effect<SessionInbox.Compaction, NotFoundError | CompactionConflictError>
   readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
   readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
   readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
@@ -274,9 +273,9 @@ export interface Interface {
     text: string
     description?: string
     metadata?: Record<string, unknown>
-    delivery?: SessionPending.Delivery
+    delivery?: SessionInbox.Delivery
     resume?: boolean
-  }) => Effect.Effect<SessionPending.Synthetic, NotFoundError | SyntheticConflictError>
+  }) => Effect.Effect<SessionInbox.Synthetic, NotFoundError | SyntheticConflictError>
   readonly revert: {
     readonly stage: (input: {
       sessionID: SessionSchema.ID
@@ -321,12 +320,12 @@ const layer = Layer.effect(
         ),
       )
 
-    const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: PendingInputRef) {
+    const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: InboxItemRef) {
       yield* result.get(input.sessionID)
-      return yield* new PendingInputConflictError(input)
+      return yield* new InboxConflictError(input)
     })
     const mutatePending = (
-      input: PendingInputRef,
+      input: InboxItemRef,
       mutation: (
         bus: Bus.Interface,
         input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
@@ -335,9 +334,9 @@ const layer = Layer.effect(
     ) =>
       Effect.uninterruptible(
         Effect.gen(function* () {
-          yield* mutation(bus, { sessionID: input.sessionID, id: input.inputID }).pipe(
+          yield* mutation(bus, { sessionID: input.sessionID, id: input.inboxID }).pipe(
             Effect.catchDefect((defect) =>
-              defect instanceof SessionPending.LifecycleConflict ? pendingConflict(input) : Effect.die(defect),
+              defect instanceof SessionInbox.LifecycleConflict ? pendingConflict(input) : Effect.die(defect),
             ),
           )
           if (wake) yield* execution.wake(input.sessionID)
@@ -529,13 +528,13 @@ const layer = Layer.effect(
         yield* result.get(sessionID)
         return yield* store.context(sessionID)
       }),
-      pending: Effect.fn("Session.pending")(function* (sessionID) {
+      inbox: Effect.fn("Session.inbox")(function* (sessionID) {
         yield* result.get(sessionID)
-        return yield* SessionPending.list(db, sessionID)
+        return yield* SessionInbox.list(db, sessionID)
       }),
-      cancelPending: Effect.fn("Session.cancelPending")((input) => mutatePending(input, SessionPending.cancel)),
-      steerPending: Effect.fn("Session.steerPending")((input) => mutatePending(input, SessionPending.steer, true)),
-      queuePending: Effect.fn("Session.queuePending")((input) => mutatePending(input, SessionPending.queue)),
+      cancelInbox: Effect.fn("Session.cancelInbox")((input) => mutatePending(input, SessionInbox.cancel)),
+      steerInbox: Effect.fn("Session.steerInbox")((input) => mutatePending(input, SessionInbox.steer, true)),
+      queueInbox: Effect.fn("Session.queueInbox")((input) => mutatePending(input, SessionInbox.queue)),
       log: (input) =>
         Stream.unwrap(
           result
@@ -564,25 +563,25 @@ const layer = Layer.effect(
               skills,
             ).pipe(Effect.provideService(FSUtil.Service, fs))
             const messageID = input.id ?? SessionMessage.ID.create()
-            const admittedInput = SessionPending.Message.make({
+            const admittedInput = SessionInbox.Item.make({
               type: "user",
-              data: { ...prompt, metadata: input.metadata },
+              payload: { ...prompt, metadata: input.metadata },
               delivery: input.delivery ?? "steer",
             })
-            const admitted = yield* SessionPending.admit(db, bus, {
+            const admitted = yield* SessionInbox.admit(db, bus, {
               id: messageID,
               sessionID: input.sessionID,
-              input: admittedInput,
+              item: admittedInput,
             }).pipe(
               Effect.catchDefect((defect) =>
-                defect instanceof SessionPending.LifecycleConflict
+                defect instanceof SessionInbox.LifecycleConflict
                   ? new PromptConflictError({ sessionID: input.sessionID, messageID })
                   : Effect.die(defect),
               ),
             )
             if (
               admitted.type !== "user" ||
-              !SessionPending.equivalent(admitted, { sessionID: input.sessionID, input: admittedInput })
+              !SessionInbox.equivalent(admitted, { sessionID: input.sessionID, item: admittedInput })
             )
               return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
             if (input.resume !== false) {
@@ -738,33 +737,35 @@ const layer = Layer.effect(
         const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
         if (!info) return yield* new DestinationNotFoundError({ directory })
         if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
-        if (current.location.directory === directory && current.location.workspaceID === input.workspaceID) return
         const project = yield* projects.resolve(directory)
         yield* persistProject(project)
-        if ((yield* execution.active).has(input.sessionID)) {
-          yield* execution.interrupt(input.sessionID)
-          yield* execution.awaitIdle(input.sessionID)
-        }
-        yield* bus.publish(
-          SessionEvent.Moved,
-          {
-            sessionID: input.sessionID,
+        const item = SessionInbox.Item.make({
+          type: "move",
+          payload: {
             location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
             projectID: project.id,
             subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
           },
-          { location: current.location },
-        )
+          delivery: input.delivery ?? "queue",
+        })
+        const inboxID = SessionMessage.ID.create()
+        yield* SessionInbox.admit(db, bus, {
+          id: inboxID,
+          sessionID: input.sessionID,
+          item,
+        })
+        yield* execution.wake(input.sessionID)
       }),
       compact: Effect.fn("Session.compact")(function* (input) {
         yield* result.get(input.sessionID)
         const inputID = input.id ?? SessionMessage.ID.create()
-        const admitted = yield* SessionPending.admitCompaction(db, bus, {
+        const admitted = yield* SessionInbox.admitCompaction(db, bus, {
           id: inputID,
           sessionID: input.sessionID,
+          delivery: input.delivery ?? "queue",
         }).pipe(
           Effect.catchDefect((defect) =>
-            defect instanceof SessionPending.LifecycleConflict
+            defect instanceof SessionInbox.LifecycleConflict
               ? new CompactionConflictError({ sessionID: input.sessionID, inputID })
               : Effect.die(defect),
           ),
@@ -804,29 +805,29 @@ const layer = Layer.effect(
           Effect.gen(function* () {
             yield* result.get(input.sessionID)
             const inputID = input.id ?? SessionMessage.ID.create()
-            const admittedInput = SessionPending.Message.make({
+            const admittedInput = SessionInbox.Item.make({
               type: "synthetic",
-              data: {
+              payload: {
                 text: input.text,
                 description: input.description,
                 metadata: input.metadata,
               },
               delivery: input.delivery ?? "steer",
             })
-            const admitted = yield* SessionPending.admit(db, bus, {
+            const admitted = yield* SessionInbox.admit(db, bus, {
               id: inputID,
               sessionID: input.sessionID,
-              input: admittedInput,
+              item: admittedInput,
             }).pipe(
               Effect.catchDefect((defect) =>
-                defect instanceof SessionPending.LifecycleConflict
+                defect instanceof SessionInbox.LifecycleConflict
                   ? new SyntheticConflictError({ sessionID: input.sessionID, inputID })
                   : Effect.die(defect),
               ),
             )
             if (
               admitted.type !== "synthetic" ||
-              !SessionPending.equivalent(admitted, { sessionID: input.sessionID, input: admittedInput })
+              !SessionInbox.equivalent(admitted, { sessionID: input.sessionID, item: admittedInput })
             )
               return yield* new SyntheticConflictError({ sessionID: input.sessionID, inputID })
             if (input.resume !== false && !(yield* result.get(input.sessionID)).revert)
@@ -839,7 +840,7 @@ const layer = Layer.effect(
         Effect.uninterruptible(
           Effect.gen(function* () {
             yield* execution.interrupt(sessionID)
-            if (options?.continue && (yield* SessionPending.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
+            if (options?.continue && (yield* SessionInbox.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
           }),
         ),
       ),

+ 10 - 6
packages/core/src/session/compaction.ts

@@ -89,6 +89,7 @@ export type ManualInput = {
   readonly session: SessionSchema.Info
   readonly messages: readonly SessionMessage.Info[]
   readonly inputID: SessionMessage.ID
+  readonly started?: boolean
 }
 
 type RequiredInput = Omit<AutoInput, "ref">
@@ -102,6 +103,7 @@ type Plan = {
   readonly prompt: string
   readonly recent: string
   readonly inputID?: SessionMessage.ID
+  readonly started?: boolean
 }
 
 export type Outcome =
@@ -248,12 +250,13 @@ const make = (dependencies: Dependencies) => {
     return { status: "failed" as const, error: input.error }
   })
   const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
-    yield* dependencies.bus.publish(SessionEvent.Compaction.Started, {
-      sessionID: plan.session.id,
-      reason: plan.reason,
-      recent: plan.recent,
-      inputID: plan.inputID,
-    })
+    if (!plan.started)
+      yield* dependencies.bus.publish(SessionEvent.Compaction.Started, {
+        sessionID: plan.session.id,
+        reason: plan.reason,
+        recent: plan.recent,
+        inputID: plan.inputID,
+      })
 
     const chunks: string[] = []
     let failure: SessionError.Error | undefined
@@ -408,6 +411,7 @@ const make = (dependencies: Dependencies) => {
       cost: resolved.cost,
       reason: "manual",
       inputID: input.inputID,
+      started: input.started,
       ...content,
     })
   })

+ 5 - 1
packages/core/src/session/execution.ts

@@ -1,6 +1,6 @@
 export * as SessionExecution from "./execution.js"
 
-import { Cause, Context, Effect, Exit, Layer } from "effect"
+import { Cause, Context, Effect, Exit, Layer, Stream } from "effect"
 import { Bus } from "../bus.js"
 import { LocationServiceMap } from "../location-service-map.js"
 import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
@@ -116,6 +116,10 @@ export const layer = Layer.effect(
           }),
         ),
     })
+    yield* bus.subscribe(SessionEvent.Moved).pipe(
+      Stream.runForEach((event) => coordinator.wake(event.data.sessionID)),
+      Effect.forkScoped,
+    )
 
     return Service.of({
       active: coordinator.active,

+ 505 - 0
packages/core/src/session/inbox.ts

@@ -0,0 +1,505 @@
+export * as SessionInbox from "./inbox.js"
+
+import { and, asc, eq, or } from "drizzle-orm"
+import { DateTime, Effect, Schema } from "effect"
+import {
+  Compaction,
+  CompactionPayload,
+  Delivery,
+  Info,
+  Item,
+  Move,
+  MovePayload,
+  Synthetic,
+  SyntheticPayload,
+  User,
+  UserPayload,
+} from "@opencode-ai/schema/session-inbox"
+import type { Database } from "../database/database.js"
+import { Bus } from "../bus.js"
+import { KeyedMutex } from "../effect/keyed-mutex.js"
+import { SessionEvent } from "./event.js"
+import { SessionMessage } from "./message.js"
+import { SessionSchema } from "./schema.js"
+import { SessionInboxTable, SessionMessageTable } from "./sql.js"
+
+type DatabaseService = Database.Interface["db"]
+
+export {
+  Compaction,
+  CompactionPayload,
+  Delivery,
+  Info,
+  Item,
+  Move,
+  MovePayload,
+  Synthetic,
+  SyntheticPayload,
+  User,
+  UserPayload,
+}
+
+/**
+ * Which pending input `promote` may consume: "steer" promotes steers only (a step
+ * boundary mid-work), while "input" also allows one queued input when no steers are
+ * waiting (the idle boundary, where the Session picks up fresh work).
+ */
+export type Promotable = "input" | "steer"
+
+const decodeUser = Schema.decodeUnknownSync(UserPayload)
+const encodeUser = Schema.encodeSync(UserPayload)
+const decodeSynthetic = Schema.decodeUnknownSync(SyntheticPayload)
+const encodeSynthetic = Schema.encodeSync(SyntheticPayload)
+const decodeCompaction = Schema.decodeUnknownSync(CompactionPayload)
+const encodeCompaction = Schema.encodeSync(CompactionPayload)
+const decodeMove = Schema.decodeUnknownSync(MovePayload)
+const encodeMove = Schema.encodeSync(MovePayload)
+const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
+const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
+type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
+
+export const serialized = <A, E, R>(sessionID: SessionSchema.ID, effect: Effect.Effect<A, E, R>) =>
+  inboxLocks.withLock(sessionID)(effect)
+
+export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInbox.LifecycleConflict", {
+  id: SessionMessage.ID,
+}) {}
+
+const fromRow = (row: typeof SessionInboxTable.$inferSelect): Info => {
+  const base = {
+    id: SessionMessage.ID.make(row.id),
+    sessionID: SessionSchema.ID.make(row.session_id),
+    timeCreated: DateTime.makeUnsafe(row.time_created),
+  }
+  if (row.type === "compaction")
+    return Compaction.make({
+      ...base,
+      type: "compaction",
+      payload: decodeCompaction(row.payload),
+      delivery: row.delivery,
+    })
+  if (row.type === "move")
+    return Move.make({ ...base, type: "move", payload: decodeMove(row.payload), delivery: row.delivery })
+  if (row.type === "user")
+    return User.make({
+      ...base,
+      type: "user",
+      payload: decodeUser(row.payload),
+      delivery: row.delivery,
+    })
+  if (row.type === "synthetic")
+    return Synthetic.make({
+      ...base,
+      type: "synthetic",
+      payload: decodeSynthetic(row.payload),
+      delivery: row.delivery,
+    })
+  throw new LifecycleConflict({ id: base.id })
+}
+
+export const find = Effect.fn("SessionInbox.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
+  const row = yield* db.select().from(SessionInboxTable).where(eq(SessionInboxTable.id, id)).get().pipe(Effect.orDie)
+  return row === undefined ? undefined : fromRow(row)
+})
+
+const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(function* (
+  db: DatabaseService,
+  sessionID: SessionSchema.ID,
+  id: SessionMessage.ID,
+  delivery: Delivery,
+) {
+  const row = yield* db
+    .select()
+    .from(SessionMessageTable)
+    .where(eq(SessionMessageTable.id, id))
+    .get()
+    .pipe(Effect.orDie)
+  if (row === undefined) return undefined
+  if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
+    return yield* Effect.die(new LifecycleConflict({ id }))
+  const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
+  const base = { id, sessionID, timeCreated: message.time.created, delivery }
+  if (message.type === "user")
+    return User.make({
+      ...base,
+      type: "user",
+      payload: decodeUser(message),
+    })
+  if (message.type === "synthetic")
+    return Synthetic.make({
+      ...base,
+      type: "synthetic",
+      payload: decodeSynthetic(message),
+    })
+  return yield* Effect.die(new LifecycleConflict({ id }))
+})
+
+export const admit = Effect.fn("SessionInbox.admit")(function* (
+  db: DatabaseService,
+  bus: Bus.Interface,
+  request: {
+    readonly id: SessionMessage.ID
+    readonly sessionID: SessionSchema.ID
+    readonly item: Item
+  },
+) {
+  const existing = yield* find(db, request.id)
+  if (existing !== undefined) {
+    if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
+    return existing
+  }
+  const promoted = yield* promotedFromMessage(db, request.sessionID, request.id, request.item.delivery)
+  if (promoted !== undefined) return promoted
+  return yield* bus
+    .publish(SessionEvent.InboxEnqueued, {
+      inboxID: request.id,
+      sessionID: request.sessionID,
+      item: request.item,
+    })
+    .pipe(
+      Effect.flatMap((event) => {
+        const base = {
+          id: request.id,
+          sessionID: request.sessionID,
+          timeCreated: event.created,
+        }
+        return Effect.succeed(Info.make({ ...base, ...request.item }))
+      }),
+      Effect.catchDefect((defect) =>
+        find(db, request.id).pipe(
+          Effect.flatMap((stored) =>
+            stored?.type === request.item.type ? Effect.succeed(stored) : Effect.die(defect),
+          ),
+        ),
+      ),
+    )
+})
+
+export const admitCompaction = Effect.fn("SessionInbox.admitCompaction")(function* (
+  db: DatabaseService,
+  bus: Bus.Interface,
+  input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID; readonly delivery: Delivery },
+) {
+  const admitted = yield* admit(db, bus, {
+    id: input.id,
+    sessionID: input.sessionID,
+    item: Item.make({ type: "compaction", payload: {}, delivery: input.delivery }),
+  })
+  if (admitted.type === "compaction") return admitted
+  return yield* Effect.die(new LifecycleConflict({ id: input.id }))
+})
+
+export const projectAdmitted = Effect.fn("SessionInbox.projectAdmitted")(function* (
+  db: DatabaseService,
+  request: {
+    readonly enqueuedSeq: number
+    readonly id: SessionMessage.ID
+    readonly sessionID: SessionSchema.ID
+    readonly item: Item
+    readonly timeCreated: DateTime.Utc
+  },
+) {
+  const message = yield* db
+    .select({ id: SessionMessageTable.id })
+    .from(SessionMessageTable)
+    .where(eq(SessionMessageTable.id, request.id))
+    .get()
+    .pipe(Effect.orDie)
+  if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
+  const stored = yield* db
+    .insert(SessionInboxTable)
+    .values({
+      id: request.id,
+      session_id: request.sessionID,
+      type: request.item.type,
+      payload:
+        request.item.type === "user"
+          ? encodeUser(request.item.payload)
+          : request.item.type === "synthetic"
+            ? encodeSynthetic(request.item.payload)
+            : request.item.type === "compaction"
+              ? encodeCompaction(request.item.payload)
+              : encodeMove(request.item.payload),
+      delivery: request.item.delivery,
+      enqueued_seq: request.enqueuedSeq,
+      time_created: DateTime.toEpochMillis(request.timeCreated),
+    })
+    .onConflictDoNothing()
+    .returning({ id: SessionInboxTable.id })
+    .get()
+    .pipe(Effect.orDie)
+  if (!stored) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
+})
+
+/**
+ * Consume one pending row at promotion. The row's content feeds the projected
+ * message insert inside the same event transaction; the deleted row is what
+ * makes the table pending-only.
+ */
+export const projectDelivered = Effect.fn("SessionInbox.projectDelivered")(function* (
+  db: DatabaseService,
+  input: PendingRef,
+) {
+  const deleted = yield* db
+    .delete(SessionInboxTable)
+    .where(and(eq(SessionInboxTable.id, input.id), eq(SessionInboxTable.session_id, input.sessionID)))
+    .returning()
+    .get()
+    .pipe(Effect.orDie)
+  if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
+  return fromRow(deleted)
+})
+
+export const projectCancelled = Effect.fn("SessionInbox.projectCancelled")(function* (
+  db: DatabaseService,
+  input: PendingRef,
+) {
+  const deleted = yield* db
+    .delete(SessionInboxTable)
+    .where(
+      and(
+        eq(SessionInboxTable.id, input.id),
+        eq(SessionInboxTable.session_id, input.sessionID),
+        or(eq(SessionInboxTable.delivery, "queue"), eq(SessionInboxTable.delivery, "steer")),
+      ),
+    )
+    .returning({ id: SessionInboxTable.id })
+    .get()
+    .pipe(Effect.orDie)
+  if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
+})
+
+const projectDelivery = Effect.fn("SessionInbox.projectDelivery")(function* (
+  db: DatabaseService,
+  input: PendingRef & { readonly from: Delivery; readonly to: Delivery },
+) {
+  const updated = yield* db
+    .update(SessionInboxTable)
+    .set({ delivery: input.to })
+    .where(
+      and(
+        eq(SessionInboxTable.id, input.id),
+        eq(SessionInboxTable.session_id, input.sessionID),
+        eq(SessionInboxTable.delivery, input.from),
+      ),
+    )
+    .returning({ id: SessionInboxTable.id })
+    .get()
+    .pipe(Effect.orDie)
+  if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
+})
+
+export const projectDeliveryChanged = Effect.fn("SessionInbox.projectDeliveryChanged")(
+  (db: DatabaseService, input: PendingRef & { readonly delivery: Delivery }) =>
+    projectDelivery(db, {
+      ...input,
+      from: input.delivery === "steer" ? "queue" : "steer",
+      to: input.delivery,
+    }),
+)
+
+export const list = Effect.fn("SessionInbox.list")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
+  const rows = yield* db
+    .select()
+    .from(SessionInboxTable)
+    .where(eq(SessionInboxTable.session_id, sessionID))
+    .orderBy(asc(SessionInboxTable.enqueued_seq))
+    .all()
+    .pipe(Effect.orDie)
+  return rows.map(fromRow)
+})
+
+export const nextQueued = Effect.fn("SessionInbox.nextQueued")(function* (
+  db: DatabaseService,
+  sessionID: SessionSchema.ID,
+) {
+  const row = yield* db
+    .select()
+    .from(SessionInboxTable)
+    .where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "queue")))
+    .orderBy(asc(SessionInboxTable.enqueued_seq))
+    .limit(1)
+    .get()
+    .pipe(Effect.orDie)
+  return row ? fromRow(row) : undefined
+})
+
+export const nextSteer = Effect.fn("SessionInbox.nextSteer")(function* (
+  db: DatabaseService,
+  sessionID: SessionSchema.ID,
+) {
+  const row = yield* db
+    .select()
+    .from(SessionInboxTable)
+    .where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer")))
+    .orderBy(asc(SessionInboxTable.enqueued_seq))
+    .limit(1)
+    .get()
+    .pipe(Effect.orDie)
+  return row ? fromRow(row) : undefined
+})
+
+/**
+ * Which pending rows count: "any" counts every row, while "input" means any
+ * item in either delivery mode.
+ */
+export type Scope = "any" | "input" | Delivery
+
+export const has = Effect.fn("SessionInbox.has")(function* (
+  db: DatabaseService,
+  sessionID: SessionSchema.ID,
+  scope: Scope,
+) {
+  const row = yield* db
+    .select({ id: SessionInboxTable.id })
+    .from(SessionInboxTable)
+    .where(
+      and(
+        eq(SessionInboxTable.session_id, sessionID),
+        scope === "any"
+          ? undefined
+          : scope === "input"
+            ? or(eq(SessionInboxTable.delivery, "steer"), eq(SessionInboxTable.delivery, "queue"))
+            : eq(SessionInboxTable.delivery, scope),
+      ),
+    )
+    .limit(1)
+    .get()
+    .pipe(Effect.orDie)
+  return row !== undefined
+})
+
+export const equivalent = (input: Info, expected: { readonly sessionID: SessionSchema.ID; readonly item: Item }) => {
+  if (
+    input.type !== expected.item.type ||
+    input.delivery !== expected.item.delivery ||
+    input.sessionID !== expected.sessionID
+  )
+    return false
+  if (input.type === "user" && expected.item.type === "user")
+    return JSON.stringify(encodeUser(input.payload)) === JSON.stringify(encodeUser(expected.item.payload))
+  if (input.type === "synthetic" && expected.item.type === "synthetic")
+    return JSON.stringify(encodeSynthetic(input.payload)) === JSON.stringify(encodeSynthetic(expected.item.payload))
+  if (input.type === "compaction" && expected.item.type === "compaction") return true
+  if (input.type === "move" && expected.item.type === "move")
+    return JSON.stringify(encodeMove(input.payload)) === JSON.stringify(encodeMove(expected.item.payload))
+  return false
+}
+
+const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
+  serialized(input.sessionID, effect).pipe(Effect.asVoid)
+
+export const cancel = Effect.fn("SessionInbox.cancel")((bus: Bus.Interface, input: PendingRef) =>
+  publishMutation(
+    input,
+    bus.publish(SessionEvent.InboxCancelled, {
+      sessionID: input.sessionID,
+      inboxID: input.id,
+    }),
+  ),
+)
+
+export const steer = Effect.fn("SessionInbox.steer")((bus: Bus.Interface, input: PendingRef) =>
+  publishMutation(
+    input,
+    bus.publish(SessionEvent.InboxDeliveryChanged, {
+      sessionID: input.sessionID,
+      inboxID: input.id,
+      delivery: "steer",
+    }),
+  ),
+)
+
+export const queue = Effect.fn("SessionInbox.queue")((bus: Bus.Interface, input: PendingRef) =>
+  publishMutation(
+    input,
+    bus.publish(SessionEvent.InboxDeliveryChanged, {
+      sessionID: input.sessionID,
+      inboxID: input.id,
+      delivery: "queue",
+    }),
+  ),
+)
+
+const publish = Effect.fn("SessionInbox.publish")(function* (
+  db: DatabaseService,
+  bus: Bus.Interface,
+  sessionID: SessionSchema.ID,
+  rows: ReadonlyArray<typeof SessionInboxTable.$inferSelect>,
+) {
+  yield* Effect.forEach(
+    rows,
+    (row) => {
+      const entry = fromRow(row)
+      if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id }))
+      return bus
+        .publish(SessionEvent.InboxDelivered, {
+          sessionID,
+          inboxID: entry.id,
+        })
+        .pipe(
+          Effect.catchDefect((defect) =>
+            defect instanceof LifecycleConflict
+              ? promotedFromMessage(db, sessionID, entry.id, entry.delivery).pipe(
+                  Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
+                )
+              : Effect.die(defect),
+          ),
+        )
+    },
+    { discard: true },
+  )
+  return rows.length
+})
+
+/**
+ * Promotes pending input into visible messages and returns the promoted count.
+ * Steers always go first; only the "input" scope may fall through to one queued
+ * input, and it then collects steers that arrived during promotion.
+ */
+export const promote = Effect.fn("SessionInbox.promote")(function* (
+  db: DatabaseService,
+  bus: Bus.Interface,
+  sessionID: SessionSchema.ID,
+  scope: Promotable,
+) {
+  return yield* serialized(
+    sessionID,
+    Effect.gen(function* () {
+      const steers = yield* db
+        .select()
+        .from(SessionInboxTable)
+        .where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer")))
+        .orderBy(asc(SessionInboxTable.enqueued_seq))
+        .all()
+        .pipe(Effect.orDie)
+      if (steers.length > 0 || scope === "steer") {
+        const control = steers.findIndex((row) => row.type === "compaction" || row.type === "move")
+        return yield* publish(db, bus, sessionID, control === -1 ? steers : steers.slice(0, control))
+      }
+
+      const queued = yield* db
+        .select()
+        .from(SessionInboxTable)
+        .where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "queue")))
+        .orderBy(asc(SessionInboxTable.enqueued_seq))
+        .limit(1)
+        .get()
+        .pipe(Effect.orDie)
+      if (!queued) return 0
+      const promoted = yield* publish(db, bus, sessionID, [queued])
+      const arrivedSteers = yield* db
+        .select()
+        .from(SessionInboxTable)
+        .where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer")))
+        .orderBy(asc(SessionInboxTable.enqueued_seq))
+        .all()
+        .pipe(Effect.orDie)
+      const control = arrivedSteers.findIndex((row) => row.type === "compaction" || row.type === "move")
+      return (
+        promoted +
+        (yield* publish(db, bus, sessionID, control === -1 ? arrivedSteers : arrivedSteers.slice(0, control)))
+      )
+    }),
+  )
+})

+ 4 - 6
packages/core/src/session/message-updater.ts

@@ -109,11 +109,10 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
       "session.renamed": () => Effect.void,
       "session.deleted": () => Effect.void,
       "session.forked": () => Effect.void,
-      "session.input.promoted": () => Effect.void,
-      "session.input.admitted": () => Effect.void,
-      "session.input.cancelled": () => Effect.void,
-      "session.input.steered": () => Effect.void,
-      "session.input.queued": () => Effect.void,
+      "session.inbox.delivered": () => Effect.void,
+      "session.inbox.enqueued": () => Effect.void,
+      "session.inbox.cancelled": () => Effect.void,
+      "session.inbox.delivery.changed": () => Effect.void,
       "session.execution.started": () => Effect.void,
       "session.execution.succeeded": () => clearCurrentRetry,
       "session.execution.failed": () => clearCurrentRetry,
@@ -380,7 +379,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
           }
         })
       },
-      "session.compaction.admitted": () => Effect.void,
       "session.compaction.started": (event) =>
         adapter.appendMessage(
           SessionMessage.CompactionRunning.make({

+ 0 - 545
packages/core/src/session/pending.ts

@@ -1,545 +0,0 @@
-export * as SessionPending from "./pending.js"
-
-import { and, asc, eq, or } from "drizzle-orm"
-import { DateTime, Effect, Schema } from "effect"
-import {
-  Compaction,
-  Delivery,
-  Info,
-  Message,
-  Synthetic,
-  SyntheticData,
-  User,
-  UserData,
-} from "@opencode-ai/schema/session-pending"
-import type { Database } from "../database/database.js"
-import { Bus } from "../bus.js"
-import { KeyedMutex } from "../effect/keyed-mutex.js"
-import { SessionEvent } from "./event.js"
-import { SessionMessage } from "./message.js"
-import { SessionSchema } from "./schema.js"
-import { SessionMessageTable, SessionPendingTable } from "./sql.js"
-
-type DatabaseService = Database.Interface["db"]
-
-export { Compaction, Delivery, Info, Message, Synthetic, SyntheticData, User, UserData }
-
-/**
- * Which pending input `promote` may consume: "steer" promotes steers only (a step
- * boundary mid-work), while "input" also allows one queued input when no steers are
- * waiting (the idle boundary, where the Session picks up fresh work).
- */
-export type Promotable = "input" | "steer"
-
-const decodeUser = Schema.decodeUnknownSync(UserData)
-const encodeUser = Schema.encodeSync(UserData)
-const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
-const encodeSynthetic = Schema.encodeSync(SyntheticData)
-const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
-const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
-type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
-
-export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
-  "SessionPending.LifecycleConflict",
-  {
-    id: SessionMessage.ID,
-  },
-) {}
-
-const fromRow = (row: typeof SessionPendingTable.$inferSelect): Info => {
-  const base = {
-    id: SessionMessage.ID.make(row.id),
-    sessionID: SessionSchema.ID.make(row.session_id),
-    timeCreated: DateTime.makeUnsafe(row.time_created),
-  }
-  if (row.type === "compaction") return Compaction.make({ ...base, type: "compaction" })
-  if (!row.delivery) throw new LifecycleConflict({ id: base.id })
-  if (row.type === "user")
-    return User.make({
-      ...base,
-      type: "user",
-      data: decodeUser(row.data),
-      delivery: row.delivery,
-    })
-  if (row.type === "synthetic")
-    return Synthetic.make({
-      ...base,
-      type: "synthetic",
-      data: decodeSynthetic(row.data),
-      delivery: row.delivery,
-    })
-  throw new LifecycleConflict({ id: base.id })
-}
-
-export const find = Effect.fn("SessionPending.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
-  const row = yield* db
-    .select()
-    .from(SessionPendingTable)
-    .where(eq(SessionPendingTable.id, id))
-    .get()
-    .pipe(Effect.orDie)
-  return row === undefined ? undefined : fromRow(row)
-})
-
-export const compaction = Effect.fn("SessionPending.compaction")(function* (
-  db: DatabaseService,
-  sessionID: SessionSchema.ID,
-) {
-  const row = yield* db
-    .select()
-    .from(SessionPendingTable)
-    .where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.type, "compaction")))
-    .orderBy(asc(SessionPendingTable.admitted_seq))
-    .limit(1)
-    .get()
-    .pipe(Effect.orDie)
-  if (!row) return
-  const entry = fromRow(row)
-  return entry.type === "compaction" ? entry : undefined
-})
-
-const promotedFromMessage = Effect.fn("SessionPending.promotedFromMessage")(function* (
-  db: DatabaseService,
-  sessionID: SessionSchema.ID,
-  id: SessionMessage.ID,
-  delivery: Delivery,
-) {
-  const row = yield* db
-    .select()
-    .from(SessionMessageTable)
-    .where(eq(SessionMessageTable.id, id))
-    .get()
-    .pipe(Effect.orDie)
-  if (row === undefined) return undefined
-  if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
-    return yield* Effect.die(new LifecycleConflict({ id }))
-  const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
-  const base = { id, sessionID, timeCreated: message.time.created, delivery }
-  if (message.type === "user")
-    return User.make({
-      ...base,
-      type: "user",
-      data: decodeUser(message),
-    })
-  if (message.type === "synthetic")
-    return Synthetic.make({
-      ...base,
-      type: "synthetic",
-      data: decodeSynthetic(message),
-    })
-  return yield* Effect.die(new LifecycleConflict({ id }))
-})
-
-export const admit = Effect.fn("SessionPending.admit")(function* (
-  db: DatabaseService,
-  bus: Bus.Interface,
-  request: {
-    readonly id: SessionMessage.ID
-    readonly sessionID: SessionSchema.ID
-    readonly input: Message
-  },
-) {
-  const existing = yield* find(db, request.id)
-  if (existing !== undefined) {
-    if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
-    return existing
-  }
-  const promoted = yield* promotedFromMessage(db, request.sessionID, request.id, request.input.delivery)
-  if (promoted !== undefined) return promoted
-  return yield* bus
-    .publish(SessionEvent.InputAdmitted, {
-      inputID: request.id,
-      sessionID: request.sessionID,
-      input: request.input,
-    })
-    .pipe(
-      Effect.flatMap((event) => {
-        const base = {
-          id: request.id,
-          sessionID: request.sessionID,
-          timeCreated: event.created,
-        }
-        return Effect.succeed(
-          request.input.type === "user"
-            ? User.make({ ...base, ...request.input })
-            : Synthetic.make({ ...base, ...request.input }),
-        )
-      }),
-      Effect.catchDefect((defect) =>
-        find(db, request.id).pipe(
-          Effect.flatMap((stored) =>
-            stored?.type === request.input.type ? Effect.succeed(stored) : Effect.die(defect),
-          ),
-        ),
-      ),
-    )
-})
-
-export const admitCompaction = Effect.fn("SessionPending.admitCompaction")(function* (
-  db: DatabaseService,
-  bus: Bus.Interface,
-  input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
-) {
-  return yield* inboxLocks.withLock(input.sessionID)(
-    Effect.gen(function* () {
-      const exact = yield* find(db, input.id)
-      if (exact) {
-        if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
-        return yield* Effect.die(new LifecycleConflict({ id: input.id }))
-      }
-      const pending = yield* compaction(db, input.sessionID)
-      if (pending) return pending
-      return yield* bus
-        .publish(SessionEvent.Compaction.Admitted, {
-          inputID: input.id,
-          sessionID: input.sessionID,
-        })
-        .pipe(
-          Effect.flatMap((event) => {
-            if (event.durable === undefined)
-              return Effect.die(new Error("Compaction admission event is missing aggregate sequence"))
-            return compaction(db, input.sessionID).pipe(
-              Effect.flatMap((stored) =>
-                stored ? Effect.succeed(stored) : Effect.die(new LifecycleConflict({ id: input.id })),
-              ),
-            )
-          }),
-          Effect.catchDefect((defect) =>
-            compaction(db, input.sessionID).pipe(
-              Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect))),
-            ),
-          ),
-        )
-    }),
-  )
-})
-
-export const projectAdmitted = Effect.fn("SessionPending.projectAdmitted")(function* (
-  db: DatabaseService,
-  request: {
-    readonly admittedSeq: number
-    readonly id: SessionMessage.ID
-    readonly sessionID: SessionSchema.ID
-    readonly input: Message
-    readonly timeCreated: DateTime.Utc
-  },
-) {
-  const message = yield* db
-    .select({ id: SessionMessageTable.id })
-    .from(SessionMessageTable)
-    .where(eq(SessionMessageTable.id, request.id))
-    .get()
-    .pipe(Effect.orDie)
-  if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
-  const stored = yield* db
-    .insert(SessionPendingTable)
-    .values({
-      id: request.id,
-      session_id: request.sessionID,
-      type: request.input.type,
-      data: request.input.type === "user" ? encodeUser(request.input.data) : encodeSynthetic(request.input.data),
-      delivery: request.input.delivery,
-      admitted_seq: request.admittedSeq,
-      time_created: DateTime.toEpochMillis(request.timeCreated),
-    })
-    .onConflictDoNothing()
-    .returning({ id: SessionPendingTable.id })
-    .get()
-    .pipe(Effect.orDie)
-  if (!stored) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
-})
-
-export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompactionAdmitted")(function* (
-  db: DatabaseService,
-  input: {
-    readonly admittedSeq: number
-    readonly id: SessionMessage.ID
-    readonly sessionID: SessionSchema.ID
-    readonly timeCreated: DateTime.Utc
-  },
-) {
-  const message = yield* db
-    .select({ id: SessionMessageTable.id })
-    .from(SessionMessageTable)
-    .where(eq(SessionMessageTable.id, input.id))
-    .get()
-    .pipe(Effect.orDie)
-  if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
-  const stored = yield* db
-    .insert(SessionPendingTable)
-    .values({
-      id: input.id,
-      session_id: input.sessionID,
-      type: "compaction",
-      data: {},
-      admitted_seq: input.admittedSeq,
-      time_created: DateTime.toEpochMillis(input.timeCreated),
-    })
-    .onConflictDoNothing()
-    .returning()
-    .get()
-    .pipe(Effect.orDie)
-  if (stored) {
-    const entry = fromRow(stored)
-    return entry.type === "compaction" ? entry : yield* Effect.die(new LifecycleConflict({ id: entry.id }))
-  }
-  const pending = yield* compaction(db, input.sessionID)
-  if (pending) return pending
-  return yield* Effect.die(new LifecycleConflict({ id: input.id }))
-})
-
-/**
- * Consume one pending row at promotion. The row's content feeds the projected
- * message insert inside the same event transaction; the deleted row is what
- * makes the table pending-only.
- */
-export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
-  db: DatabaseService,
-  input: PendingRef,
-) {
-  if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
-  const deleted = yield* db
-    .delete(SessionPendingTable)
-    .where(and(eq(SessionPendingTable.id, input.id), eq(SessionPendingTable.session_id, input.sessionID)))
-    .returning()
-    .get()
-    .pipe(Effect.orDie)
-  if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
-  const stored = fromRow(deleted)
-  if (stored.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: input.id }))
-  return stored
-})
-
-export const projectCancelled = Effect.fn("SessionPending.projectCancelled")(function* (
-  db: DatabaseService,
-  input: PendingRef,
-) {
-  const deleted = yield* db
-    .delete(SessionPendingTable)
-    .where(
-      and(
-        eq(SessionPendingTable.id, input.id),
-        eq(SessionPendingTable.session_id, input.sessionID),
-        or(eq(SessionPendingTable.delivery, "queue"), eq(SessionPendingTable.delivery, "steer")),
-      ),
-    )
-    .returning({ id: SessionPendingTable.id })
-    .get()
-    .pipe(Effect.orDie)
-  if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
-})
-
-const projectDelivery = Effect.fn("SessionPending.projectDelivery")(function* (
-  db: DatabaseService,
-  input: PendingRef & { readonly from: Delivery; readonly to: Delivery },
-) {
-  const updated = yield* db
-    .update(SessionPendingTable)
-    .set({ delivery: input.to })
-    .where(
-      and(
-        eq(SessionPendingTable.id, input.id),
-        eq(SessionPendingTable.session_id, input.sessionID),
-        eq(SessionPendingTable.delivery, input.from),
-      ),
-    )
-    .returning({ id: SessionPendingTable.id })
-    .get()
-    .pipe(Effect.orDie)
-  if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
-})
-
-export const projectSteered = Effect.fn("SessionPending.projectSteered")((db: DatabaseService, input: PendingRef) =>
-  projectDelivery(db, { ...input, from: "queue", to: "steer" }),
-)
-
-export const projectQueued = Effect.fn("SessionPending.projectQueued")((db: DatabaseService, input: PendingRef) =>
-  projectDelivery(db, { ...input, from: "steer", to: "queue" }),
-)
-
-export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
-  db: DatabaseService,
-  input: { readonly sessionID: SessionSchema.ID },
-) {
-  const deleted = yield* db
-    .delete(SessionPendingTable)
-    .where(and(eq(SessionPendingTable.session_id, input.sessionID), eq(SessionPendingTable.type, "compaction")))
-    .returning()
-    .get()
-    .pipe(Effect.orDie)
-  if (deleted) {
-    const stored = fromRow(deleted)
-    return stored.type === "compaction" ? stored : yield* Effect.die(new LifecycleConflict({ id: stored.id }))
-  }
-  return undefined
-})
-
-export const list = Effect.fn("SessionPending.list")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
-  const rows = yield* db
-    .select()
-    .from(SessionPendingTable)
-    .where(eq(SessionPendingTable.session_id, sessionID))
-    .orderBy(asc(SessionPendingTable.admitted_seq))
-    .all()
-    .pipe(Effect.orDie)
-  return rows.map(fromRow)
-})
-
-/**
- * Which pending rows count: "any" counts every row including compaction, while
- * delivery scopes are blocked behind a pending compaction barrier. "input" means
- * any model-facing input, steered or queued.
- */
-export type Scope = "any" | "input" | Delivery
-
-export const has = Effect.fn("SessionPending.has")(function* (
-  db: DatabaseService,
-  sessionID: SessionSchema.ID,
-  scope: Scope,
-) {
-  if (scope !== "any" && (yield* compaction(db, sessionID))) return false
-  const row = yield* db
-    .select({ id: SessionPendingTable.id })
-    .from(SessionPendingTable)
-    .where(
-      and(
-        eq(SessionPendingTable.session_id, sessionID),
-        scope === "any"
-          ? undefined
-          : scope === "input"
-            ? or(eq(SessionPendingTable.delivery, "steer"), eq(SessionPendingTable.delivery, "queue"))
-            : eq(SessionPendingTable.delivery, scope),
-      ),
-    )
-    .limit(1)
-    .get()
-    .pipe(Effect.orDie)
-  return row !== undefined
-})
-
-export const equivalent = (
-  input: User | Synthetic,
-  expected: { readonly sessionID: SessionSchema.ID; readonly input: Message },
-) => {
-  if (
-    input.type !== expected.input.type ||
-    input.delivery !== expected.input.delivery ||
-    input.sessionID !== expected.sessionID
-  )
-    return false
-  if (input.type === "user" && expected.input.type === "user")
-    return JSON.stringify(encodeUser(input.data)) === JSON.stringify(encodeUser(expected.input.data))
-  if (input.type === "synthetic" && expected.input.type === "synthetic")
-    return JSON.stringify(encodeSynthetic(input.data)) === JSON.stringify(encodeSynthetic(expected.input.data))
-  return false
-}
-
-const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
-  inboxLocks.withLock(input.sessionID)(effect).pipe(Effect.asVoid)
-
-export const cancel = Effect.fn("SessionPending.cancel")((bus: Bus.Interface, input: PendingRef) =>
-  publishMutation(
-    input,
-    bus.publish(SessionEvent.InputCancelled, {
-      sessionID: input.sessionID,
-      inputID: input.id,
-    }),
-  ),
-)
-
-export const steer = Effect.fn("SessionPending.steer")((bus: Bus.Interface, input: PendingRef) =>
-  publishMutation(
-    input,
-    bus.publish(SessionEvent.InputSteered, {
-      sessionID: input.sessionID,
-      inputID: input.id,
-    }),
-  ),
-)
-
-export const queue = Effect.fn("SessionPending.queue")((bus: Bus.Interface, input: PendingRef) =>
-  publishMutation(
-    input,
-    bus.publish(SessionEvent.InputQueued, {
-      sessionID: input.sessionID,
-      inputID: input.id,
-    }),
-  ),
-)
-
-const publish = Effect.fn("SessionPending.publish")(function* (
-  db: DatabaseService,
-  bus: Bus.Interface,
-  sessionID: SessionSchema.ID,
-  rows: ReadonlyArray<typeof SessionPendingTable.$inferSelect>,
-) {
-  if (yield* compaction(db, sessionID)) return 0
-  yield* Effect.forEach(
-    rows,
-    (row) => {
-      const entry = fromRow(row)
-      if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id }))
-      return bus
-        .publish(SessionEvent.InputPromoted, {
-          sessionID,
-          inputID: entry.id,
-        })
-        .pipe(
-          Effect.catchDefect((defect) =>
-            defect instanceof LifecycleConflict
-              ? promotedFromMessage(db, sessionID, entry.id, entry.delivery).pipe(
-                  Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
-                )
-              : Effect.die(defect),
-          ),
-        )
-    },
-    { discard: true },
-  )
-  return rows.length
-})
-
-/**
- * Promotes pending input into visible messages and returns the promoted count.
- * Steers always go first; only the "input" scope may fall through to one queued
- * input, and it then collects steers that arrived during promotion.
- */
-export const promote = Effect.fn("SessionPending.promote")(function* (
-  db: DatabaseService,
-  bus: Bus.Interface,
-  sessionID: SessionSchema.ID,
-  scope: Promotable,
-) {
-  return yield* inboxLocks.withLock(sessionID)(
-    Effect.gen(function* () {
-      if (yield* compaction(db, sessionID)) return 0
-      const steers = yield* db
-        .select()
-        .from(SessionPendingTable)
-        .where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "steer")))
-        .orderBy(asc(SessionPendingTable.admitted_seq))
-        .all()
-        .pipe(Effect.orDie)
-      if (steers.length > 0 || scope === "steer") return yield* publish(db, bus, sessionID, steers)
-
-      const queued = yield* db
-        .select()
-        .from(SessionPendingTable)
-        .where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "queue")))
-        .orderBy(asc(SessionPendingTable.admitted_seq))
-        .limit(1)
-        .get()
-        .pipe(Effect.orDie)
-      if (!queued) return 0
-      const promoted = yield* publish(db, bus, sessionID, [queued])
-      const arrivedSteers = yield* db
-        .select()
-        .from(SessionPendingTable)
-        .where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "steer")))
-        .orderBy(asc(SessionPendingTable.admitted_seq))
-        .all()
-        .pipe(Effect.orDie)
-      return promoted + (yield* publish(db, bus, sessionID, arrivedSteers))
-    }),
-  )
-})

+ 29 - 49
packages/core/src/session/projector.ts

@@ -10,10 +10,10 @@ import { Model } from "../model.js"
 import { SessionEvent } from "./event.js"
 import { SessionMessage } from "./message.js"
 import { SessionMessageUpdater } from "./message-updater.js"
-import { SessionPending } from "./pending.js"
+import { SessionInbox } from "./inbox.js"
 import { Workspace } from "../workspace.js"
 import { InstructionState } from "./instruction-state.js"
-import { SessionPendingTable, SessionMessageTable, SessionTable } from "./sql.js"
+import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
 import { Slug } from "../util/slug.js"
 import { Money } from "@opencode-ai/schema/money"
 import { AbsolutePath, RelativePath } from "../schema.js"
@@ -470,14 +470,15 @@ const layer = Layer.effectDiscard(
     )
     yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
     yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event))
-    yield* bus.project(SessionEvent.InputPromoted, (event) =>
+    yield* bus.project(SessionEvent.InboxDelivered, (event) =>
       Effect.gen(function* () {
         if (event.durable === undefined)
           return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
-        const input = yield* SessionPending.projectPromoted(db, {
-          id: event.data.inputID,
+        const input = yield* SessionInbox.projectDelivered(db, {
+          id: event.data.inboxID,
           sessionID: event.data.sessionID,
         })
+        if (input.type === "compaction" || input.type === "move") return
         yield* insertMessage(
           db,
           event,
@@ -485,33 +486,33 @@ const layer = Layer.effectDiscard(
             ? {
                 id: input.id,
                 type: "user",
-                metadata: input.data.metadata,
-                text: input.data.text,
-                files: input.data.files,
-                agents: input.data.agents,
-                skills: input.data.skills,
+                metadata: input.payload.metadata,
+                text: input.payload.text,
+                files: input.payload.files,
+                agents: input.payload.agents,
+                skills: input.payload.skills,
                 time: { created: event.created },
               }
             : {
                 id: input.id,
                 type: "synthetic",
-                text: input.data.text,
-                description: input.data.description,
-                metadata: input.data.metadata,
+                text: input.payload.text,
+                description: input.payload.description,
+                metadata: input.payload.metadata,
                 time: { created: event.created },
               },
         )
       }),
     )
-    yield* bus.project(SessionEvent.InputAdmitted, (event) =>
+    yield* bus.project(SessionEvent.InboxEnqueued, (event) =>
       Effect.gen(function* () {
         if (event.durable === undefined)
           return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
-        yield* SessionPending.projectAdmitted(db, {
-          admittedSeq: event.durable.seq,
-          id: event.data.inputID,
+        yield* SessionInbox.projectAdmitted(db, {
+          enqueuedSeq: event.durable.seq,
+          id: event.data.inboxID,
           sessionID: event.data.sessionID,
-          input: event.data.input,
+          item: event.data.item,
           timeCreated: event.created,
         })
         yield* db
@@ -522,34 +523,17 @@ const layer = Layer.effectDiscard(
           .pipe(Effect.orDie)
       }),
     )
-    yield* bus.project(SessionEvent.InputCancelled, (event) =>
-      SessionPending.projectCancelled(db, {
-        id: event.data.inputID,
+    yield* bus.project(SessionEvent.InboxCancelled, (event) =>
+      SessionInbox.projectCancelled(db, {
+        id: event.data.inboxID,
         sessionID: event.data.sessionID,
       }),
     )
-    yield* bus.project(SessionEvent.InputSteered, (event) =>
-      SessionPending.projectSteered(db, {
-        id: event.data.inputID,
+    yield* bus.project(SessionEvent.InboxDeliveryChanged, (event) =>
+      SessionInbox.projectDeliveryChanged(db, {
+        id: event.data.inboxID,
         sessionID: event.data.sessionID,
-      }),
-    )
-    yield* bus.project(SessionEvent.InputQueued, (event) =>
-      SessionPending.projectQueued(db, {
-        id: event.data.inputID,
-        sessionID: event.data.sessionID,
-      }),
-    )
-    yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
-      Effect.gen(function* () {
-        if (event.durable === undefined)
-          return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
-        yield* SessionPending.projectCompactionAdmitted(db, {
-          admittedSeq: event.durable.seq,
-          id: event.data.inputID,
-          sessionID: event.data.sessionID,
-          timeCreated: event.created,
-        })
+        delivery: event.data.delivery,
       }),
     )
     yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
@@ -596,8 +580,6 @@ const layer = Layer.effectDiscard(
         yield* InstructionState.advanceEpoch(db, event.data.sessionID, event.durable.seq)
         if (event.durable === undefined)
           return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
-        if (event.data.reason === "manual")
-          yield* SessionPending.settleCompaction(db, { sessionID: event.data.sessionID })
       }),
     )
     yield* bus.project(SessionEvent.Compaction.Failed, (event) =>
@@ -605,8 +587,6 @@ const layer = Layer.effectDiscard(
         yield* run(db, event)
         if (event.durable === undefined)
           return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
-        if (event.data.reason === "manual")
-          yield* SessionPending.settleCompaction(db, { sessionID: event.data.sessionID })
       }),
     )
     yield* bus.project(SessionEvent.RevertEvent.Staged, (event) =>
@@ -650,11 +630,11 @@ const layer = Layer.effectDiscard(
           .run()
           .pipe(Effect.orDie)
         yield* db
-          .delete(SessionPendingTable)
+          .delete(SessionInboxTable)
           .where(
             and(
-              eq(SessionPendingTable.session_id, event.data.sessionID),
-              gte(SessionPendingTable.admitted_seq, boundary.seq),
+              eq(SessionInboxTable.session_id, event.data.sessionID),
+              gte(SessionInboxTable.enqueued_seq, boundary.seq),
             ),
           )
           .run()

+ 71 - 29
packages/core/src/session/runner/llm.ts

@@ -17,7 +17,7 @@ import { InstructionState } from "../instruction-state.js"
 import { SessionCompaction } from "../compaction.js"
 import { SessionContext } from "../context.js"
 import { SessionEvent } from "../event.js"
-import { SessionPending } from "../pending.js"
+import { SessionInbox } from "../inbox.js"
 import { SessionModelRequest } from "../model-request.js"
 import { SessionMessage } from "../message.js"
 import { SessionSchema } from "../schema.js"
@@ -125,13 +125,19 @@ const layer = Layer.effect(
       readonly sessionID: SessionSchema.ID
       readonly force: boolean
     }) {
-      if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "any"))) return
+      let force = input.force
+      if (!force && !(yield* SessionInbox.has(db, input.sessionID, "any"))) return
       yield* settleStaleToolCalls(input.sessionID)
-      yield* runPendingCompaction(input.sessionID)
-      if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "input"))) return
-      do {
-        yield* runSteps(input.sessionID)
-      } while (yield* SessionPending.has(db, input.sessionID, "input"))
+      while (true) {
+        if (yield* runPendingCompaction(input.sessionID)) {
+          force = false
+          continue
+        }
+        if (yield* runPendingMove(input.sessionID)) return
+        if (!force && !(yield* SessionInbox.has(db, input.sessionID, "input"))) return
+        if (yield* runSteps(input.sessionID)) return
+        force = false
+      }
     })
 
     /**
@@ -140,12 +146,13 @@ const layer = Layer.effect(
      */
     const runSteps = Effect.fn("SessionRunner.runSteps")(function* (sessionID: SessionSchema.ID) {
       // Fresh work may promote queued input; later steps absorb steers only.
-      let promotable: SessionPending.Promotable = "input"
+      let promotable: SessionInbox.Promotable = "input"
       let step = 1
       while (true) {
+        if (yield* runPendingCompaction(sessionID)) continue
+        if (yield* runPendingMove(sessionID)) return true
         const result = yield* runStep(sessionID, promotable, step)
-        yield* runPendingCompaction(sessionID)
-        if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
+        if (!result.needsContinuation && !(yield* SessionInbox.has(db, sessionID, "steer"))) return false
         promotable = "steer"
         step = result.step + 1
       }
@@ -154,7 +161,7 @@ const layer = Layer.effect(
     /** Completes one logical model step, transparently retrying or rebuilding after compaction. */
     const runStep = Effect.fnUntraced(function* (
       sessionID: SessionSchema.ID,
-      promotable: SessionPending.Promotable,
+      promotable: SessionInbox.Promotable,
       step: number,
     ) {
       // Minting message identity before any attempt lets retries resume the same durable
@@ -182,7 +189,7 @@ const layer = Layer.effect(
               .pipe(Effect.andThen(Effect.fail(failure.cause))),
           ),
         )
-      let currentPromotable: SessionPending.Promotable | undefined = promotable
+      let currentPromotable: SessionInbox.Promotable | undefined = promotable
       let currentStep = step
       // Overflow recovery is one-shot: a call after recovery must not recover another overflow.
       let recoverOverflow = true
@@ -225,7 +232,7 @@ const layer = Layer.effect(
      */
     const callModel = Effect.fn("SessionRunner.callModel")(function* (
       sessionID: SessionSchema.ID,
-      promotable: SessionPending.Promotable | undefined,
+      promotable: SessionInbox.Promotable | undefined,
       step: number,
       recoverOverflow: boolean,
       assistantMessageID: SessionMessage.ID,
@@ -234,7 +241,7 @@ const layer = Layer.effect(
       // Establish what the model knows before admitting what the user said, so
       // a blocked first step leaves pending inputs untouched.
       yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
-      const promoted = promotable ? yield* SessionPending.promote(db, bus, selected.session.id, promotable) : 0
+      const promoted = promotable ? yield* SessionInbox.promote(db, bus, selected.session.id, promotable) : 0
       if (promoted > 0) yield* startTitle(sessionID)
       // Promoted input opens a fresh step allowance.
       const currentStep = promoted > 0 ? 1 : step
@@ -245,7 +252,7 @@ const layer = Layer.effect(
       // Make room: history must fit the context window before the call. A pending manual
       // compaction owns this instead; the runner executes it between steps.
       const compactionInput = { session, messages: loaded.messages, model, ref: resolved.ref, cost: resolved.cost }
-      if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
+      if (compaction.required(compactionInput)) {
         const compacted = yield* compaction.compact(compactionInput)
         if (compacted.status === "completed")
           return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
@@ -482,36 +489,71 @@ const layer = Layer.effect(
     const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
       sessionID: SessionSchema.ID,
     ) {
-      const pending = yield* SessionPending.compaction(db, sessionID)
-      if (!pending) return
-      const session = yield* getSession(sessionID)
       return yield* Effect.uninterruptibleMask((restore) =>
         Effect.gen(function* () {
+          const pending = yield* SessionInbox.serialized(
+            sessionID,
+            Effect.gen(function* () {
+              const selected =
+                (yield* SessionInbox.nextSteer(db, sessionID)) ?? (yield* SessionInbox.nextQueued(db, sessionID))
+              if (selected?.type !== "compaction") return
+              yield* bus.publishAll([
+                [SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
+                [SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "", inputID: selected.id }],
+              ])
+              return selected
+            }),
+          )
+          if (pending?.type !== "compaction") return false
+          const session = yield* getSession(sessionID)
           const compacted = yield* restore(
             Effect.gen(function* () {
               return yield* compaction.compactManual({
                 session,
                 messages: yield* store.context(sessionID),
                 inputID: pending.id,
+                started: true,
               })
             }),
           ).pipe(Effect.exit)
-          if (Exit.isSuccess(compacted)) return
-          const unsettled = yield* SessionPending.compaction(db, sessionID)
-          if (unsettled)
-            yield* bus.publish(SessionEvent.Compaction.Failed, {
-              sessionID,
-              reason: "manual",
-              error: Cause.hasInterruptsOnly(compacted.cause)
-                ? { type: "aborted", message: "Compaction cancelled" }
-                : { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
-              inputID: unsettled.id,
-            })
+          if (Exit.isSuccess(compacted)) return true
+          yield* bus.publish(SessionEvent.Compaction.Failed, {
+            sessionID,
+            reason: "manual",
+            error: Cause.hasInterruptsOnly(compacted.cause)
+              ? { type: "aborted", message: "Compaction cancelled" }
+              : { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
+            inputID: pending.id,
+          })
           return yield* Effect.failCause(compacted.cause)
         }),
       )
     })
 
+    const runPendingMove = Effect.fn("SessionRunner.runPendingMove")(function* (sessionID: SessionSchema.ID) {
+      return yield* SessionInbox.serialized(
+        sessionID,
+        Effect.gen(function* () {
+          const pending =
+            (yield* SessionInbox.nextSteer(db, sessionID)) ?? (yield* SessionInbox.nextQueued(db, sessionID))
+          if (pending?.type !== "move") return false
+          yield* bus.publishAll([
+            [SessionEvent.InboxDelivered, { sessionID, inboxID: pending.id }],
+            [
+              SessionEvent.Moved,
+              {
+                sessionID,
+                location: pending.payload.location,
+                projectID: pending.payload.projectID,
+                subpath: pending.payload.subpath,
+              },
+            ],
+          ])
+          return true
+        }),
+      )
+    })
+
     /** Closes stale tool calls left active by an earlier interrupted drain. */
     const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
       sessionID: SessionSchema.ID,

+ 27 - 5
packages/core/src/session/sql.ts

@@ -3,7 +3,7 @@ import { sql } from "drizzle-orm"
 import { directoryColumn, pathColumn } from "../database/path.js"
 import { ProjectTable } from "../project/sql.js"
 import type { SessionMessage } from "./message.js"
-import type { SessionPending } from "./pending.js"
+import type { SessionInbox } from "./inbox.js"
 import type { FileDiff } from "@opencode-ai/schema/file-diff"
 import { PermissionV1 } from "../v1/permission.js"
 import { Project } from "../project.js"
@@ -12,7 +12,7 @@ import { Workspace } from "../workspace.js"
 import { Timestamps } from "../database/schema.sql.js"
 import type { Instruction } from "@opencode-ai/schema/instruction"
 import type { Session } from "@opencode-ai/schema/session"
-import type { SyntheticData, UserData } from "@opencode-ai/schema/session-pending"
+import type { CompactionPayload, MovePayload, SyntheticPayload, UserPayload } from "@opencode-ai/schema/session-inbox"
 import type { RevertV1 } from "@opencode-ai/schema/session-revert"
 import type { Schema } from "effect"
 
@@ -101,9 +101,9 @@ export const SessionPendingTable = sqliteTable(
       .$type<SessionSchema.ID>()
       .notNull()
       .references(() => SessionTable.id, { onDelete: "cascade" }),
-    type: text().$type<SessionPending.Info["type"]>().notNull(),
-    data: text({ mode: "json" }).$type<UserData | SyntheticData | Record<string, never>>().notNull(),
-    delivery: text().$type<SessionPending.Delivery>(),
+    type: text().$type<SessionInbox.Info["type"]>().notNull(),
+    data: text({ mode: "json" }).$type<UserPayload | SyntheticPayload | Record<string, never>>().notNull(),
+    delivery: text().$type<SessionInbox.Delivery>(),
     admitted_seq: integer().notNull(),
     time_created: integer()
       .notNull()
@@ -118,6 +118,28 @@ export const SessionPendingTable = sqliteTable(
   ],
 )
 
+export const SessionInboxTable = sqliteTable(
+  "session_inbox",
+  {
+    id: text().$type<SessionMessage.ID>().primaryKey(),
+    session_id: text()
+      .$type<SessionSchema.ID>()
+      .notNull()
+      .references(() => SessionTable.id, { onDelete: "cascade" }),
+    type: text().$type<SessionInbox.Info["type"]>().notNull(),
+    payload: text({ mode: "json" }).$type<UserPayload | SyntheticPayload | CompactionPayload | MovePayload>().notNull(),
+    delivery: text().$type<SessionInbox.Delivery>().notNull(),
+    enqueued_seq: integer().notNull(),
+    time_created: integer()
+      .notNull()
+      .$default(() => Date.now()),
+  },
+  (table) => [
+    index("session_inbox_session_delivery_seq_idx").on(table.session_id, table.delivery, table.enqueued_seq),
+    uniqueIndex("session_inbox_session_enqueued_seq_idx").on(table.session_id, table.enqueued_seq),
+  ],
+)
+
 export const InstructionEntryTable = sqliteTable(
   "instruction_entry",
   {

+ 114 - 0
packages/core/test/bus.test.ts

@@ -455,6 +455,120 @@ describe("Bus", () => {
     }),
   )
 
+  it.effect("publishes a durable batch atomically in provided order", () =>
+    Effect.gen(function* () {
+      const bus = yield* Bus.Service
+      const { db } = yield* Database.Service
+      const aggregateID = Event.ID.create()
+      const observed = new Array<string>()
+      yield* bus.project(SyncMessage, (event) =>
+        Effect.sync(() => {
+          observed.push(`project:${event.data.text}`)
+        }),
+      )
+      yield* bus.listen((event) =>
+        event.type === SyncMessage.type
+          ? Effect.gen(function* () {
+              const text = (event.data as { readonly text: string }).text
+              const row = yield* db
+                .select({ seq: EventSequenceTable.seq })
+                .from(EventSequenceTable)
+                .where(eq(EventSequenceTable.aggregate_id, aggregateID))
+                .get()
+                .pipe(Effect.orDie)
+              observed.push(`notify:${text}:${row?.seq}`)
+            })
+          : Effect.void,
+      )
+
+      const events = yield* bus.publishAll([
+        [SyncMessage, { id: aggregateID, text: "first" }],
+        [SyncMessage, { id: aggregateID, text: "second" }],
+      ])
+
+      expect(events.map((event) => event.durable.seq)).toEqual([Event.Seq.make(0), Event.Seq.make(1)])
+      expect(observed).toEqual(["project:first", "project:second", "notify:first:1", "notify:second:1"])
+      expect(
+        (yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all()).map(
+          (row) => row.seq,
+        ),
+      ).toEqual([0, 1])
+    }),
+  )
+
+  it.effect("rolls back every batch event when a projector fails", () =>
+    Effect.gen(function* () {
+      const bus = yield* Bus.Service
+      const { db } = yield* Database.Service
+      const aggregateID = Event.ID.create()
+      const notifications = new Array<string>()
+      yield* db.run("CREATE TABLE IF NOT EXISTS event_batch_probe (value text NOT NULL)")
+      yield* db.run("DELETE FROM event_batch_probe")
+      yield* bus.project(SyncMessage, (event) =>
+        db
+          .run(`INSERT INTO event_batch_probe (value) VALUES ('${event.data.text}')`)
+          .pipe(
+            Effect.orDie,
+            Effect.andThen(event.data.text === "second" ? Effect.die("projector failed") : Effect.void),
+          ),
+      )
+      yield* bus.listen((event) =>
+        Effect.sync(() => {
+          notifications.push(event.type)
+        }),
+      )
+
+      const exit = yield* bus
+        .publishAll([
+          [SyncMessage, { id: aggregateID, text: "first" }],
+          [SyncMessage, { id: aggregateID, text: "second" }],
+        ])
+        .pipe(Effect.exit)
+
+      expect(String(exit)).toContain("projector failed")
+      expect(yield* db.all("SELECT value FROM event_batch_probe")).toEqual([])
+      expect(yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all()).toEqual([])
+      expect(
+        yield* db.select().from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).all(),
+      ).toEqual([])
+      expect(notifications).toEqual([])
+    }),
+  )
+
+  it.effect("does not interleave a concurrent publish with batch notifications", () =>
+    Effect.gen(function* () {
+      const bus = yield* Bus.Service
+      const aggregateID = Event.ID.create()
+      const firstObserved = yield* Deferred.make<void>()
+      const continueNotifications = yield* Deferred.make<void>()
+      const observed = new Array<string>()
+      yield* bus.listen((event) => {
+        if (event.type !== SyncMessage.type) return Effect.void
+        const text = (event.data as { readonly text: string }).text
+        return Effect.sync(() => observed.push(text)).pipe(
+          Effect.andThen(text === "first" ? Deferred.succeed(firstObserved, undefined) : Effect.void),
+          Effect.andThen(text === "first" ? Deferred.await(continueNotifications) : Effect.void),
+        )
+      })
+
+      const batch = yield* bus
+        .publishAll([
+          [SyncMessage, { id: aggregateID, text: "first" }],
+          [SyncMessage, { id: aggregateID, text: "second" }],
+        ])
+        .pipe(Effect.forkScoped)
+      yield* Deferred.await(firstObserved)
+      const single = yield* bus.publish(SyncMessage, { id: aggregateID, text: "third" }).pipe(Effect.forkScoped)
+      yield* Effect.yieldNow
+
+      expect(observed).toEqual(["first"])
+      yield* Deferred.succeed(continueNotifications, undefined)
+      yield* Fiber.join(batch)
+      yield* Fiber.join(single)
+      expect(observed).toEqual(["first", "second", "third"])
+    }),
+  )
+
   it.effect("replays durable aggregate events after a sequence and tails new events", () =>
     Effect.gen(function* () {
       const bus = yield* Bus.Service

+ 3 - 3
packages/core/test/plugin/promise.test.ts

@@ -11,7 +11,7 @@ import { PluginPromise } from "@opencode-ai/core/plugin/promise"
 import { WebSearch } from "@opencode-ai/core/websearch"
 import { Session } from "@opencode-ai/core/session"
 import { SessionMessage } from "@opencode-ai/core/session/message"
-import { SessionPending } from "@opencode-ai/core/session/pending"
+import { SessionInbox } from "@opencode-ai/core/session/inbox"
 import { Tool } from "@opencode-ai/core/tool"
 import { Provider } from "@opencode-ai/core/provider"
 import { define } from "@opencode-ai/plugin/promise/plugin"
@@ -61,12 +61,12 @@ describe("fromPromise", () => {
           synthetic: (value) => {
             seen = value
             return Effect.succeed(
-              SessionPending.Synthetic.make({
+              SessionInbox.Synthetic.make({
                 id: SessionMessage.ID.make(input.id),
                 sessionID: Session.ID.make(input.sessionID),
                 timeCreated: DateTime.makeUnsafe(0),
                 type: "synthetic",
-                data: {
+                payload: {
                   text: input.text,
                   metadata: input.metadata,
                 },

+ 16 - 12
packages/core/test/session-compact.test.ts

@@ -15,7 +15,6 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
 import { Session } from "@opencode-ai/core/session"
 import { SessionCompaction } from "@opencode-ai/core/session/compaction"
 import { SessionEvent } from "@opencode-ai/core/session/event"
-import { SessionPending } from "@opencode-ai/core/session/pending"
 import { SessionMessage } from "@opencode-ai/core/session/message"
 import { SessionProjector } from "@opencode-ai/core/session/projector"
 import { SessionExecution } from "@opencode-ai/core/session/execution"
@@ -81,7 +80,7 @@ const it = testEffect(
 )
 
 describe("Session.compact", () => {
-  it.effect("durably admits and coalesces manual compaction", () =>
+  it.effect("durably stacks manual compaction", () =>
     Effect.gen(function* () {
       requests = []
       const session = yield* Session.Service
@@ -89,18 +88,18 @@ describe("Session.compact", () => {
       const created = yield* session.create({ location })
 
       const messageID = SessionMessage.ID.create()
-      yield* bus.publish(SessionEvent.InputAdmitted, {
+      yield* bus.publish(SessionEvent.InboxEnqueued, {
         sessionID: created.id,
-        inputID: messageID,
-        input: {
+        inboxID: messageID,
+        item: {
           type: "user",
-          data: { text: "Please compact this session history." },
+          payload: { text: "Please compact this session history." },
           delivery: "steer",
         },
       })
-      yield* bus.publish(SessionEvent.InputPromoted, {
+      yield* bus.publish(SessionEvent.InboxDelivered, {
         sessionID: created.id,
-        inputID: messageID,
+        inboxID: messageID,
       })
 
       expect(yield* session.compact({ id: messageID, sessionID: created.id }).pipe(Effect.flip)).toMatchObject({
@@ -110,12 +109,17 @@ describe("Session.compact", () => {
       const first = yield* session.compact({ sessionID: created.id })
       const second = yield* session.compact({ sessionID: created.id })
 
-      expect(second.id).toBe(first.id)
+      expect(second.id).not.toBe(first.id)
       expect(requests).toHaveLength(0)
-      expect(yield* SessionPending.compaction((yield* Database.Service).db, created.id)).toMatchObject({
-        id: first.id,
-      })
+      expect(yield* session.inbox(created.id)).toEqual([
+        expect.objectContaining({ id: first.id, type: "compaction", delivery: "queue" }),
+        expect.objectContaining({ id: second.id, type: "compaction", delivery: "queue" }),
+      ])
       expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toBeUndefined()
+
+      const steered = yield* session.create({ location })
+      const steer = yield* session.compact({ sessionID: steered.id, delivery: "steer" })
+      expect(steer).toMatchObject({ type: "compaction", delivery: "steer" })
     }),
   )
 })

+ 26 - 23
packages/core/test/session-create.test.ts

@@ -19,7 +19,7 @@ import { Session } from "@opencode-ai/core/session"
 import { SessionMessage } from "@opencode-ai/core/session/message"
 import { SessionProjector } from "@opencode-ai/core/session/projector"
 import { SessionExecution } from "@opencode-ai/core/session/execution"
-import { SessionPending } from "@opencode-ai/core/session/pending"
+import { SessionInbox } from "@opencode-ai/core/session/inbox"
 import { SessionEvent } from "@opencode-ai/core/session/event"
 import { SessionTable } from "@opencode-ai/core/session/sql"
 import { SessionStore } from "@opencode-ai/core/session/store"
@@ -267,9 +267,9 @@ describe("Session.create", () => {
         text: "First",
         resume: false,
       })
-      yield* SessionPending.promote(db, bus, parent.id, "steer")
+      yield* SessionInbox.promote(db, bus, parent.id, "steer")
       yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false })
-      yield* SessionPending.promote(db, bus, parent.id, "steer")
+      yield* SessionInbox.promote(db, bus, parent.id, "steer")
 
       const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
       const parentContext = yield* session.context(parent.id)
@@ -289,24 +289,24 @@ describe("Session.create", () => {
         durable: { seq: 0 },
         data: { sessionID: forked.id, parentID: parent.id },
       })
-      expect(yield* SessionPending.find(db, forkContext[0].id)).toBeUndefined()
-      expect(yield* SessionPending.find(db, forkContext[1].id)).toBeUndefined()
+      expect(yield* SessionInbox.find(db, forkContext[0].id)).toBeUndefined()
+      expect(yield* SessionInbox.find(db, forkContext[1].id)).toBeUndefined()
       expect(
         yield* session.prompt({ id: forkContext[0].id, sessionID: forked.id, text: "First", resume: false }),
-      ).toMatchObject({ id: forkContext[0].id, type: "user", data: { text: "First" } })
+      ).toMatchObject({ id: forkContext[0].id, type: "user", payload: { text: "First" } })
 
       yield* session.prompt({
         sessionID: parent.id,
         text: "Parent changed",
         resume: false,
       })
-      yield* SessionPending.promote(db, bus, parent.id, "steer")
+      yield* SessionInbox.promote(db, bus, parent.id, "steer")
       yield* session.prompt({
         sessionID: forked.id,
         text: "Child continues",
         resume: false,
       })
-      yield* SessionPending.promote(db, bus, forked.id, "steer")
+      yield* SessionInbox.promote(db, bus, forked.id, "steer")
 
       expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
       expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
@@ -316,7 +316,7 @@ describe("Session.create", () => {
           (event): number | undefined => event.durable?.seq,
         ),
       ).toEqual([0, 5, 6])
-      expect(yield* SessionPending.find(db, admitted.id)).toBeUndefined()
+      expect(yield* SessionInbox.find(db, admitted.id)).toBeUndefined()
     }),
   )
 
@@ -327,7 +327,7 @@ describe("Session.create", () => {
       const { db } = yield* Database.Service
       const parent = yield* session.create({ location })
       yield* session.prompt({ sessionID: parent.id, text: "First", resume: false })
-      yield* SessionPending.promote(db, bus, parent.id, "steer")
+      yield* SessionInbox.promote(db, bus, parent.id, "steer")
 
       const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
       const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, forked.id)).get().pipe(Effect.orDie)
@@ -359,13 +359,13 @@ describe("Session.create", () => {
         text: "First",
         resume: false,
       })
-      yield* SessionPending.promote(db, bus, parent.id, "steer")
+      yield* SessionInbox.promote(db, bus, parent.id, "steer")
       const second = yield* session.prompt({
         sessionID: parent.id,
         text: "Second",
         resume: false,
       })
-      yield* SessionPending.promote(db, bus, parent.id, "steer")
+      yield* SessionInbox.promote(db, bus, parent.id, "steer")
       const assistantMessageID = SessionMessage.ID.create()
       const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
       yield* bus.publish(SessionEvent.Step.Started, {
@@ -496,7 +496,7 @@ describe("Session.create", () => {
         text: "Hello",
         resume: false,
       })
-      yield* SessionPending.promote(db, bus, created.id, "steer")
+      yield* SessionInbox.promote(db, bus, created.id, "steer")
 
       expect(
         Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(3), Stream.runCollect)),
@@ -504,10 +504,13 @@ describe("Session.create", () => {
         { durable: { seq: 0 }, type: "session.created" },
         {
           durable: { seq: 1 },
-          type: "session.input.admitted",
-          data: { input: { type: "user", data: { text: "Hello" }, delivery: "steer" } },
+          type: "session.inbox.enqueued",
+          data: {
+            inboxID: expect.any(String),
+            item: { type: "user", payload: { text: "Hello" }, delivery: "steer" },
+          },
         },
-        { durable: { seq: 2 }, type: "session.input.promoted" },
+        { durable: { seq: 2 }, type: "session.inbox.delivered" },
       ])
     }),
   )
@@ -523,7 +526,7 @@ describe("Session.create", () => {
         text: "Replay lifecycle",
         resume: false,
       })
-      yield* SessionPending.promote(sourceDb, sourceEvents, created.id, "steer")
+      yield* SessionInbox.promote(sourceDb, sourceEvents, created.id, "steer")
       const serialized = (yield* sourceDb
         .select()
         .from(EventTable)
@@ -563,17 +566,17 @@ describe("Session.create", () => {
 
         expect(yield* store.get(created.id)).toBeUndefined()
         expect(yield* bus.replayAll(serialized.slice(0, 2))).toBe(created.id)
-        expect(yield* SessionPending.find(db, admitted.id)).toMatchObject({
+        expect(yield* SessionInbox.find(db, admitted.id)).toMatchObject({
           id: admitted.id,
           sessionID: created.id,
           type: "user",
-          data: { text: "Replay lifecycle" },
+          payload: { text: "Replay lifecycle" },
           delivery: "steer",
         })
         expect(yield* store.context(created.id)).toEqual([])
 
         expect(yield* bus.replayAll(serialized.slice(2))).toBe(created.id)
-        expect(yield* SessionPending.find(db, admitted.id)).toBeUndefined()
+        expect(yield* SessionInbox.find(db, admitted.id)).toBeUndefined()
         expect(yield* store.context(created.id)).toMatchObject([
           { id: admitted.id, type: "user", text: "Replay lifecycle" },
         ])
@@ -587,8 +590,8 @@ describe("Session.create", () => {
             .pipe(Effect.orDie)).map((event) => [event.seq, event.type]),
         ).toEqual([
           [0, Bus.versionedType(SessionEvent.Created.type, 1)],
-          [1, Bus.versionedType(SessionEvent.InputAdmitted.type, 1)],
-          [2, Bus.versionedType(SessionEvent.InputPromoted.type, 1)],
+          [1, Bus.versionedType(SessionEvent.InboxEnqueued.type, 1)],
+          [2, Bus.versionedType(SessionEvent.InboxDelivered.type, 1)],
         ])
       }).pipe(Effect.provide(Layer.fresh(targetLayer)))
     }),
@@ -808,7 +811,7 @@ describe("SessionTransfer", () => {
       ])
 
       yield* session.prompt({ sessionID, text: "Continue", resume: false })
-      yield* SessionPending.promote(db, bus, sessionID, "steer")
+      yield* SessionInbox.promote(db, bus, sessionID, "steer")
 
       expect((yield* session.messages({ sessionID, order: "asc" })).map((message) => message.type)).toEqual([
         "user",

+ 14 - 11
packages/core/test/session-generate.test.ts

@@ -38,7 +38,7 @@ import {
   InstructionBlobTable,
   InstructionStateTable,
   SessionMessageTable,
-  SessionPendingTable,
+  SessionInboxTable,
   SessionTable,
 } from "@opencode-ai/core/session/sql"
 import { SessionStore } from "@opencode-ai/core/session/store"
@@ -170,9 +170,9 @@ const durableState = (db: Database.Interface["db"], sessionID: SessionSchema.ID)
       .pipe(Effect.orDie),
     pending: db
       .select()
-      .from(SessionPendingTable)
-      .where(eq(SessionPendingTable.session_id, sessionID))
-      .orderBy(asc(SessionPendingTable.admitted_seq))
+      .from(SessionInboxTable)
+      .where(eq(SessionInboxTable.session_id, sessionID))
+      .orderBy(asc(SessionInboxTable.enqueued_seq))
       .all()
       .pipe(Effect.orDie),
     instructions: db
@@ -225,12 +225,15 @@ it.effect("generates from fresh settled Session context without durable mutation
     const { db, bus, instructions } = yield* setup
     yield* InstructionState.prepare(db, bus, instructions, sessionID)
     const existing = SessionMessage.ID.create()
-    yield* bus.publish(SessionEvent.InputAdmitted, {
+    yield* bus.publish(SessionEvent.InboxEnqueued, {
       sessionID,
-      inputID: existing,
-      input: { type: "user", data: { text: "Existing durable context" }, delivery: "steer" },
+      inboxID: existing,
+      item: { type: "user", payload: { text: "Existing durable context" }, delivery: "steer" },
+    })
+    yield* bus.publish(SessionEvent.InboxDelivered, {
+      sessionID,
+      inboxID: existing,
     })
-    yield* bus.publish(SessionEvent.InputPromoted, { sessionID, inputID: existing })
     const settledAssistant = SessionMessage.ID.create()
     yield* bus.publish(SessionEvent.Step.Started, {
       sessionID,
@@ -275,10 +278,10 @@ it.effect("generates from fresh settled Session context without durable mutation
       input: {},
       executed: false,
     })
-    yield* bus.publish(SessionEvent.InputAdmitted, {
+    yield* bus.publish(SessionEvent.InboxEnqueued, {
       sessionID,
-      inputID: SessionMessage.ID.create(),
-      input: { type: "user", data: { text: "Queued input must remain invisible" }, delivery: "queue" },
+      inboxID: SessionMessage.ID.create(),
+      item: { type: "user", payload: { text: "Queued input must remain invisible" }, delivery: "queue" },
     })
     instruction = "Changed context"
     const before = yield* durableState(db, sessionID)

+ 18 - 14
packages/core/test/session-move.test.ts

@@ -34,7 +34,7 @@ const it = testEffect(
 )
 
 describe("Session.move", () => {
-  it.effect("moves a session whose source directory no longer exists", () =>
+  it.effect("enqueues one move when the source directory no longer exists", () =>
     Effect.acquireRelease(
       Effect.promise(() => tmpdir()),
       (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@@ -49,24 +49,28 @@ describe("Session.move", () => {
 
           yield* session.move({ sessionID: created.id, directory: destination })
 
-          expect((yield* session.get(created.id)).location.directory).toBe(destination)
-          const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
-          expect(messages).toEqual([
-            expect.objectContaining({
-              type: "location-switched",
-              location: { directory: destination },
-              projectID: Project.ID.global,
-              previous: {
-                location: { directory: path.join(tmp.path, "deleted") },
+          expect((yield* session.get(created.id)).location.directory).toBe(
+            AbsolutePath.make(path.join(tmp.path, "deleted")),
+          )
+          expect(yield* session.inbox(created.id)).toMatchObject([
+            {
+              type: "move",
+              delivery: "queue",
+              payload: {
+                location: { directory: destination },
                 projectID: Project.ID.global,
-                subpath: "",
               },
-              subpath: "",
-            }),
+            },
           ])
 
           yield* session.move({ sessionID: created.id, directory: destination })
-          expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toEqual(messages)
+          expect(yield* session.inbox(created.id)).toHaveLength(2)
+
+          const steered = yield* session.create({
+            location: Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "other")) }),
+          })
+          yield* session.move({ sessionID: steered.id, directory: destination, delivery: "steer" })
+          expect(yield* session.inbox(steered.id)).toMatchObject([{ type: "move", delivery: "steer" }])
         }),
       ),
     ),

+ 19 - 19
packages/core/test/session-projector.test.ts

@@ -20,11 +20,11 @@ import { Money } from "@opencode-ai/schema/money"
 import { SessionProjector } from "@opencode-ai/core/session/projector"
 import { SessionExecution } from "@opencode-ai/core/session/execution"
 import { fromRow } from "@opencode-ai/core/session/info"
-import { SessionPending } from "@opencode-ai/core/session/pending"
+import { SessionInbox } from "@opencode-ai/core/session/inbox"
 import { Shell } from "@opencode-ai/schema/shell"
 import {
   InstructionStateTable,
-  SessionPendingTable,
+  SessionInboxTable,
   SessionMessageTable,
   SessionTable,
 } from "@opencode-ai/core/session/sql"
@@ -81,7 +81,7 @@ describe("SessionProjector", () => {
         .run()
       const bus = yield* Bus.Service
       const inputID = SessionMessage.ID.make("msg_manual_compaction")
-      yield* SessionPending.admitCompaction(db, bus, { id: inputID, sessionID })
+      yield* SessionInbox.admitCompaction(db, bus, { id: inputID, sessionID, delivery: "queue" })
 
       yield* bus.publish(SessionEvent.Compaction.Failed, {
         sessionID,
@@ -89,7 +89,7 @@ describe("SessionProjector", () => {
         error: { type: "compaction.failed", message: "Auto compaction failed" },
       })
 
-      expect(yield* SessionPending.compaction(db, sessionID)).toMatchObject({ id: inputID })
+      expect(yield* SessionInbox.find(db, inputID)).toMatchObject({ id: inputID })
     }),
   )
 
@@ -247,29 +247,29 @@ describe("SessionProjector", () => {
         .pipe(Effect.orDie)
       const bus = yield* Bus.Service
 
-      yield* bus.publish(SessionEvent.InputAdmitted, {
+      yield* bus.publish(SessionEvent.InboxEnqueued, {
         sessionID,
-        inputID: SessionMessage.ID.make("msg_first"),
-        input: { type: "user", data: { text: "first" }, delivery: "steer" },
+        inboxID: SessionMessage.ID.make("msg_first"),
+        item: { type: "user", payload: { text: "first" }, delivery: "steer" },
       })
       yield* bus.publish(
-        SessionEvent.InputPromoted,
+        SessionEvent.InboxDelivered,
         {
           sessionID,
-          inputID: SessionMessage.ID.make("msg_first"),
+          inboxID: SessionMessage.ID.make("msg_first"),
         },
         { id: Event.ID.make("evt_z") },
       )
-      yield* bus.publish(SessionEvent.InputAdmitted, {
+      yield* bus.publish(SessionEvent.InboxEnqueued, {
         sessionID,
-        inputID: SessionMessage.ID.make("msg_second"),
-        input: { type: "user", data: { text: "second" }, delivery: "steer" },
+        inboxID: SessionMessage.ID.make("msg_second"),
+        item: { type: "user", payload: { text: "second" }, delivery: "steer" },
       })
       yield* bus.publish(
-        SessionEvent.InputPromoted,
+        SessionEvent.InboxDelivered,
         {
           sessionID,
-          inputID: SessionMessage.ID.make("msg_second"),
+          inboxID: SessionMessage.ID.make("msg_second"),
         },
         { id: Event.ID.make("evt_a") },
       )
@@ -320,20 +320,20 @@ describe("SessionProjector", () => {
         .pipe(Effect.orDie)
       const bus = yield* Bus.Service
       const id = SessionMessage.ID.make("msg_admitted")
-      const admitted = yield* SessionPending.admit(db, bus, {
+      const admitted = yield* SessionInbox.admit(db, bus, {
         id,
         sessionID,
-        input: { type: "user", data: { text: "promote me" }, delivery: "steer" },
+        item: { type: "user", payload: { text: "promote me" }, delivery: "steer" },
       })
       if (!admitted) return yield* Effect.die("Prompt admission failed")
 
-      const event = yield* bus.publish(SessionEvent.InputPromoted, {
+      const event = yield* bus.publish(SessionEvent.InboxDelivered, {
         sessionID,
-        inputID: id,
+        inboxID: id,
       })
 
       expect(
-        yield* db.select().from(SessionPendingTable).where(eq(SessionPendingTable.id, id)).get().pipe(Effect.orDie),
+        yield* db.select().from(SessionInboxTable).where(eq(SessionInboxTable.id, id)).get().pipe(Effect.orDie),
       ).toBeUndefined()
       expect(
         yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie),

+ 83 - 87
packages/core/test/session-prompt.test.ts

@@ -21,8 +21,8 @@ import { Session } from "@opencode-ai/core/session"
 import { SessionMessage } from "@opencode-ai/core/session/message"
 import { SessionProjector } from "@opencode-ai/core/session/projector"
 import { SessionExecution } from "@opencode-ai/core/session/execution"
-import { SessionPending } from "@opencode-ai/core/session/pending"
-import { SessionPendingTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
+import { SessionInbox } from "@opencode-ai/core/session/inbox"
+import { SessionInboxTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
 import { SessionStore } from "@opencode-ai/core/session/store"
 import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
 import type { LocationServices } from "@opencode-ai/core/location-services"
@@ -100,11 +100,11 @@ const setup = Effect.gen(function* () {
     .pipe(Effect.orDie)
 })
 
-const admitted = (id: SessionMessage.ID) => Database.Service.use(({ db }) => SessionPending.find(db, id))
+const admitted = (id: SessionMessage.ID) => Database.Service.use(({ db }) => SessionInbox.find(db, id))
 const admittedCount = Database.Service.use(({ db }) =>
   db
     .select()
-    .from(SessionPendingTable)
+    .from(SessionInboxTable)
     .all()
     .pipe(
       Effect.orDie,
@@ -227,13 +227,13 @@ describe("Session.prompt", () => {
         resume: false,
       })
 
-      expect(message.data.text).toBe("Fix the failing tests")
+      expect(message.payload.text).toBe("Fix the failing tests")
       expect(yield* session.messages({ sessionID })).toEqual([])
       expect(yield* admitted(message.id)).toMatchObject({
         id: message.id,
         sessionID,
         type: "user",
-        data: { text: "Fix the failing tests" },
+        payload: { text: "Fix the failing tests" },
         delivery: "steer",
       })
     }),
@@ -251,7 +251,7 @@ describe("Session.prompt", () => {
         text: "boundary",
         resume: false,
       })
-      yield* SessionPending.promote(db, bus, sessionID, "steer")
+      yield* SessionInbox.promote(db, bus, sessionID, "steer")
       const stale = SessionMessage.ID.make("msg_stale_assistant")
       yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
       yield* bus.publish(SessionEvent.RevertEvent.Staged, {
@@ -268,7 +268,7 @@ describe("Session.prompt", () => {
           (row) => row.id,
         ),
       ).not.toContainAnyValues([boundary.id, stale])
-      expect(yield* SessionPending.find(db, boundary.id)).toBeUndefined()
+      expect(yield* SessionInbox.find(db, boundary.id)).toBeUndefined()
     }),
   )
 
@@ -283,7 +283,7 @@ describe("Session.prompt", () => {
         text: "boundary",
         resume: false,
       })
-      yield* SessionPending.promote(db, bus, sessionID, "steer")
+      yield* SessionInbox.promote(db, bus, sessionID, "steer")
       yield* bus.publish(SessionEvent.RevertEvent.Staged, {
         sessionID,
         revert: { messageID: boundary.id, files: [] },
@@ -293,11 +293,11 @@ describe("Session.prompt", () => {
       const completion = yield* session.synthetic({ sessionID, text: "stale completion" })
 
       expect(wakeCalls).toEqual([])
-      expect(yield* SessionPending.find(db, completion.id)).toMatchObject({ type: "synthetic" })
+      expect(yield* SessionInbox.find(db, completion.id)).toMatchObject({ type: "synthetic" })
 
       yield* session.revert.commit(sessionID)
 
-      expect(yield* SessionPending.find(db, completion.id)).toBeUndefined()
+      expect(yield* SessionInbox.find(db, completion.id)).toBeUndefined()
     }),
   )
 
@@ -315,7 +315,7 @@ describe("Session.prompt", () => {
         resume: false,
       })
 
-      expect(message.data.files).toEqual([
+      expect(message.payload.files).toEqual([
         {
           data: uri.slice(uri.indexOf(",") + 1),
           mime: "image/png",
@@ -326,7 +326,7 @@ describe("Session.prompt", () => {
       ])
       const stored = yield* admitted(message.id)
       expect(stored?.type).toBe("user")
-      if (stored?.type === "user") expect(stored.data.files).toEqual(message.data.files)
+      if (stored?.type === "user") expect(stored.payload.files).toEqual(message.payload.files)
     }),
   )
 
@@ -347,14 +347,14 @@ describe("Session.prompt", () => {
         resume: false,
       })
 
-      expect(message.data.files).toHaveLength(1)
-      expect(message.data.files?.[0]).toMatchObject({
+      expect(message.payload.files).toHaveLength(1)
+      expect(message.payload.files?.[0]).toMatchObject({
         mime: "text/plain",
         source: { type: "uri", uri: sourceUri.href },
         name: "main.ts",
       })
       expect(
-        Buffer.from(message.data.files?.[0]?.data ?? "", "base64")
+        Buffer.from(message.payload.files?.[0]?.data ?? "", "base64")
           .toString("utf8")
           .replace(/\r$/, ""),
       ).toBe('import { describe, expect } from "bun:test"')
@@ -374,13 +374,13 @@ describe("Session.prompt", () => {
         resume: false,
       })
 
-      expect(message.data.files).toHaveLength(1)
-      expect(message.data.files?.[0]).toMatchObject({
+      expect(message.payload.files).toHaveLength(1)
+      expect(message.payload.files?.[0]).toMatchObject({
         mime: "application/x-directory",
         source: { type: "uri", uri },
         name: "source",
       })
-      expect(Buffer.from(message.data.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain(
+      expect(Buffer.from(message.payload.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain(
         "session-prompt.test.ts",
       )
     }),
@@ -408,7 +408,7 @@ describe("Session.prompt", () => {
         resume: false,
       })
 
-      expect(message.data.files).toEqual([
+      expect(message.payload.files).toEqual([
         {
           data: bytes.toString("base64"),
           mime: "image/png",
@@ -417,7 +417,7 @@ describe("Session.prompt", () => {
         },
       ])
       const stored = yield* admitted(message.id)
-      expect(stored?.type === "user" ? stored.data.files : undefined).toEqual(message.data.files)
+      expect(stored?.type === "user" ? stored.payload.files : undefined).toEqual(message.payload.files)
     }),
   )
 
@@ -440,7 +440,7 @@ describe("Session.prompt", () => {
         resume: false,
       })
 
-      expect(message.data.files).toEqual([
+      expect(message.payload.files).toEqual([
         {
           data: "AA==",
           mime: "image/png",
@@ -463,7 +463,7 @@ describe("Session.prompt", () => {
         resume: false,
       })
 
-      expect(message.data.files).toEqual([
+      expect(message.payload.files).toEqual([
         {
           data: Buffer.from("export const value = 1\n").toString("base64"),
           mime: "text/plain",
@@ -512,20 +512,20 @@ describe("Session.prompt", () => {
 
       yield* session.prompt({ sessionID, text: "First", resume: false })
       yield* session.prompt({ sessionID, text: "Second", resume: false })
-      yield* SessionPending.promote(db, bus, sessionID, "steer")
+      yield* SessionInbox.promote(db, bus, sessionID, "steer")
       const streamed = Array.from(yield* Fiber.join(fiber))
 
       expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
-        [0, "session.input.admitted"],
-        [1, "session.input.admitted"],
-        [2, "session.input.promoted"],
-        [3, "session.input.promoted"],
+        [0, "session.inbox.enqueued"],
+        [1, "session.inbox.enqueued"],
+        [2, "session.inbox.delivered"],
+        [3, "session.inbox.delivered"],
       ])
       expect(
         Array.from(
           yield* publicEvents({ sessionID, after: streamed[0].durable?.seq }).pipe(Stream.take(1), Stream.runCollect),
         ).map((event): [number | undefined, string] => [event.durable?.seq, event.type]),
-      ).toEqual([[1, "session.input.admitted"]])
+      ).toEqual([[1, "session.inbox.enqueued"]])
     }),
   )
 
@@ -593,12 +593,12 @@ describe("Session.prompt", () => {
       const { db } = yield* Database.Service
       const input = { sessionID, id: messageID, text: "Fix the failing tests", resume: false }
       const first = yield* session.prompt(input)
-      yield* SessionPending.promote(db, bus, sessionID, "steer")
+      yield* SessionInbox.promote(db, bus, sessionID, "steer")
       yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, sessionID)).run().pipe(Effect.orDie)
 
       const retried = yield* session.prompt(input)
 
-      expect(retried).toMatchObject({ id: first.id, type: "user", data: { text: first.data.text } })
+      expect(retried).toMatchObject({ id: first.id, type: "user", payload: { text: first.payload.text } })
       expect(yield* session.messages({ sessionID })).toMatchObject([
         { id: messageID, type: "user", text: "Fix the failing tests" },
       ])
@@ -613,11 +613,11 @@ describe("Session.prompt", () => {
       const { db } = yield* Database.Service
       const input = { sessionID, id: messageID, text: "Fix the failing tests", resume: false }
       yield* session.prompt(input)
-      yield* SessionPending.promote(db, bus, sessionID, "steer")
+      yield* SessionInbox.promote(db, bus, sessionID, "steer")
 
       const retried = yield* session.prompt({ ...input, delivery: "queue" })
 
-      expect(retried).toMatchObject({ id: messageID, type: "user", data: { text: input.text } })
+      expect(retried).toMatchObject({ id: messageID, type: "user", payload: { text: input.text } })
       expect(yield* admitted(messageID)).toBeUndefined()
     }),
   )
@@ -708,7 +708,7 @@ describe("Session.prompt", () => {
       expect(messages[1]).toEqual(messages[0])
       expect(yield* session.messages({ sessionID })).toEqual([])
       expect(yield* admittedCount).toBe(1)
-      expect(yield* eventCount(Bus.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
+      expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxEnqueued.type, 1))).toBe(1)
     }),
   )
 
@@ -726,11 +726,11 @@ describe("Session.prompt", () => {
       })
 
       yield* Effect.all(
-        [SessionPending.promote(db, bus, sessionID, "steer"), SessionPending.promote(db, bus, sessionID, "steer")],
+        [SessionInbox.promote(db, bus, sessionID, "steer"), SessionInbox.promote(db, bus, sessionID, "steer")],
         { concurrency: "unbounded" },
       )
 
-      expect(yield* eventCount(Bus.versionedType(SessionEvent.InputPromoted.type, 1))).toBe(1)
+      expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxDelivered.type, 1))).toBe(1)
       expect(yield* admitted(messageID)).toBeUndefined()
       expect(yield* session.messages({ sessionID })).toMatchObject([
         { id: messageID, type: "user", text: "Promote once" },
@@ -761,11 +761,7 @@ describe("Session.prompt", () => {
         .pipe(Effect.orDie)
 
       yield* bus.remove(sessionID)
-      yield* db
-        .delete(SessionPendingTable)
-        .where(eq(SessionPendingTable.session_id, sessionID))
-        .run()
-        .pipe(Effect.orDie)
+      yield* db.delete(SessionInboxTable).where(eq(SessionInboxTable.session_id, sessionID)).run().pipe(Effect.orDie)
       yield* db
         .delete(SessionMessageTable)
         .where(eq(SessionMessageTable.session_id, sessionID))
@@ -785,12 +781,12 @@ describe("Session.prompt", () => {
       expect(yield* admitted(messageID)).toMatchObject({
         id: messageID,
         type: "user",
-        data: { text: "Replay pending" },
+        payload: { text: "Replay pending" },
       })
       expect(yield* admitted(syntheticID)).toMatchObject({
         id: syntheticID,
         type: "synthetic",
-        data: { text: "Replay synthetic" },
+        payload: { text: "Replay synthetic" },
       })
       expect(yield* session.messages({ sessionID })).toEqual([])
       expect(wakeCalls).toEqual([])
@@ -923,7 +919,7 @@ describe("Session.prompt", () => {
       const failure = yield* session.prompt({ ...input, metadata: { source: "plugin" } }).pipe(Effect.flip)
 
       expect(retried).toEqual(first)
-      expect(first.data.metadata).toEqual({ source: "api" })
+      expect(first.payload.metadata).toEqual({ source: "api" })
       expect(failure._tag).toBe("Session.PromptConflictError")
     }),
   )
@@ -949,14 +945,14 @@ describe("Session.prompt", () => {
         type: "synthetic",
         sessionID,
         delivery: "steer",
-        data: {
+        payload: {
           text: "Background work completed",
           description: "shell completion",
           metadata: { job: "shell" },
         },
       })
 
-      yield* SessionPending.promote(db, bus, sessionID, "steer")
+      yield* SessionInbox.promote(db, bus, sessionID, "steer")
 
       expect(yield* session.messages({ sessionID })).toMatchObject([
         {
@@ -981,15 +977,15 @@ describe("Session.prompt", () => {
       const entries = yield* Effect.all([session.synthetic(input), session.synthetic(input)], {
         concurrency: "unbounded",
       })
-      yield* SessionPending.promote(database.db, bus, sessionID, "steer")
+      yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
       const promotedRetry = yield* session.synthetic(input)
       const failure = yield* session.synthetic({ ...input, text: "Different completion" }).pipe(Effect.flip)
 
       expect(entries[1]).toEqual(entries[0])
-      expect(promotedRetry).toMatchObject({ id: messageID, type: "synthetic", data: { text: "Completed" } })
+      expect(promotedRetry).toMatchObject({ id: messageID, type: "synthetic", payload: { text: "Completed" } })
       expect(failure).toMatchObject({ _tag: "Session.SyntheticConflictError", sessionID, inputID: messageID })
       expect(yield* admittedCount).toBe(0)
-      expect(yield* eventCount(Bus.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
+      expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxEnqueued.type, 1))).toBe(1)
     }),
   )
 
@@ -1008,11 +1004,11 @@ describe("Session.prompt", () => {
       })
 
       expect(input.delivery).toBe("queue")
-      expect(yield* SessionPending.has(db, sessionID, "input")).toBe(true)
-      expect(yield* SessionPending.promote(db, bus, sessionID, "steer")).toBe(0)
+      expect(yield* SessionInbox.has(db, sessionID, "input")).toBe(true)
+      expect(yield* SessionInbox.promote(db, bus, sessionID, "steer")).toBe(0)
       expect(yield* session.messages({ sessionID })).toEqual([])
-      expect(yield* SessionPending.promote(db, bus, sessionID, "input")).toBe(1)
-      expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false)
+      expect(yield* SessionInbox.promote(db, bus, sessionID, "input")).toBe(1)
+      expect(yield* SessionInbox.has(db, sessionID, "input")).toBe(false)
       expect(yield* session.messages({ sessionID })).toMatchObject([
         { id: input.id, type: "synthetic", text: "Queued completion" },
       ])
@@ -1038,7 +1034,7 @@ describe("Session.prompt", () => {
         resume: false,
       })
 
-      yield* SessionPending.promote(db, bus, sessionID, "steer")
+      yield* SessionInbox.promote(db, bus, sessionID, "steer")
 
       expect(
         (yield* session.messages({ sessionID, order: "asc" })).map((message) =>
@@ -1049,11 +1045,11 @@ describe("Session.prompt", () => {
   )
 })
 
-describe("Session.pending", () => {
+describe("Session.inbox", () => {
   it.effect("fails for an unknown session", () =>
     Effect.gen(function* () {
       const session = yield* Session.Service
-      expect(yield* session.pending(Session.ID.make("ses_missing")).pipe(Effect.flip)).toMatchObject({
+      expect(yield* session.inbox(Session.ID.make("ses_missing")).pipe(Effect.flip)).toMatchObject({
         _tag: "Session.NotFoundError",
       })
     }),
@@ -1075,34 +1071,34 @@ describe("Session.pending", () => {
       })
       const second = yield* session.prompt({ sessionID, text: "Second steer", resume: false })
 
-      expect(yield* session.pending(sessionID)).toMatchObject([
+      expect(yield* session.inbox(sessionID)).toMatchObject([
         { id: first.id, type: "user", delivery: "steer" },
         { id: queued.id, type: "synthetic", delivery: "queue" },
         { id: second.id, type: "user", delivery: "steer" },
       ])
 
-      expect(yield* SessionPending.promote(db, bus, sessionID, "input")).toBe(2)
-      expect(yield* session.pending(sessionID)).toMatchObject([{ id: queued.id, type: "synthetic" }])
+      expect(yield* SessionInbox.promote(db, bus, sessionID, "input")).toBe(2)
+      expect(yield* session.inbox(sessionID)).toMatchObject([{ id: queued.id, type: "synthetic" }])
 
-      expect(yield* SessionPending.promote(db, bus, sessionID, "input")).toBe(1)
-      expect(yield* session.pending(sessionID)).toEqual([])
+      expect(yield* SessionInbox.promote(db, bus, sessionID, "input")).toBe(1)
+      expect(yield* session.inbox(sessionID)).toEqual([])
     }),
   )
 
-  it.effect("lists an unhandled compaction barrier until it settles", () =>
+  it.effect("lists an unhandled compaction until it is cancelled", () =>
     Effect.gen(function* () {
       yield* setup
       const session = yield* Session.Service
       const { db } = yield* Database.Service
 
       const barrier = yield* session.compact({ sessionID })
-      expect(yield* SessionPending.has(db, sessionID, "any")).toBe(true)
-      expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false)
-      expect(yield* session.pending(sessionID)).toMatchObject([{ id: barrier.id, type: "compaction" }])
+      expect(yield* SessionInbox.has(db, sessionID, "any")).toBe(true)
+      expect(yield* SessionInbox.has(db, sessionID, "input")).toBe(true)
+      expect(yield* session.inbox(sessionID)).toMatchObject([{ id: barrier.id, type: "compaction" }])
 
-      yield* SessionPending.settleCompaction(db, { sessionID })
-      expect(yield* SessionPending.has(db, sessionID, "any")).toBe(false)
-      expect(yield* session.pending(sessionID)).toEqual([])
+      yield* session.cancelInbox({ sessionID, inboxID: barrier.id })
+      expect(yield* SessionInbox.has(db, sessionID, "any")).toBe(false)
+      expect(yield* session.inbox(sessionID)).toEqual([])
     }),
   )
 
@@ -1119,16 +1115,16 @@ describe("Session.pending", () => {
         resume: false,
       })
 
-      yield* session.cancelPending({ sessionID, inputID })
+      yield* session.cancelInbox({ sessionID, inboxID: inputID })
 
-      expect(yield* session.pending(sessionID)).toEqual([])
-      expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
-      expect(yield* session.cancelPending({ sessionID, inputID }).pipe(Effect.flip)).toMatchObject({
-        _tag: "Session.PendingInputConflictError",
+      expect(yield* session.inbox(sessionID)).toEqual([])
+      expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxCancelled.type, 1))).toBe(1)
+      expect(yield* session.cancelInbox({ sessionID, inboxID: inputID }).pipe(Effect.flip)).toMatchObject({
+        _tag: "Session.InboxConflictError",
         sessionID,
-        inputID,
+        inboxID: inputID,
       })
-      expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
+      expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxCancelled.type, 1))).toBe(1)
 
       const retried = yield* session.prompt({
         id: inputID,
@@ -1154,33 +1150,33 @@ describe("Session.pending", () => {
       const alreadySteered = yield* session.prompt({ sessionID, text: "Already steer", resume: false })
       wakeCalls.length = 0
 
-      yield* session.steerPending({ sessionID, inputID: queued.id })
+      yield* session.steerInbox({ sessionID, inboxID: queued.id })
 
-      expect(yield* session.pending(sessionID)).toMatchObject([
+      expect(yield* session.inbox(sessionID)).toMatchObject([
         { id: queued.id, delivery: "steer" },
         { id: alreadySteered.id, delivery: "steer" },
       ])
       expect(wakeCalls).toEqual([sessionID])
-      expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
+      expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxDeliveryChanged.type, 1))).toBe(1)
 
       wakeCalls.length = 0
-      yield* session.queuePending({ sessionID, inputID: queued.id })
-      expect(yield* session.pending(sessionID)).toMatchObject([
+      yield* session.queueInbox({ sessionID, inboxID: queued.id })
+      expect(yield* session.inbox(sessionID)).toMatchObject([
         { id: queued.id, delivery: "queue" },
         { id: alreadySteered.id, delivery: "steer" },
       ])
       expect(wakeCalls).toEqual([])
-      expect(yield* eventCount(Bus.versionedType(SessionEvent.InputQueued.type, 1))).toBe(1)
+      expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxDeliveryChanged.type, 1))).toBe(2)
 
-      expect(yield* session.steerPending({ sessionID, inputID: alreadySteered.id }).pipe(Effect.flip)).toMatchObject({
-        _tag: "Session.PendingInputConflictError",
+      expect(yield* session.steerInbox({ sessionID, inboxID: alreadySteered.id }).pipe(Effect.flip)).toMatchObject({
+        _tag: "Session.InboxConflictError",
         sessionID,
-        inputID: alreadySteered.id,
+        inboxID: alreadySteered.id,
       })
-      yield* session.cancelPending({ sessionID, inputID: alreadySteered.id })
+      yield* session.cancelInbox({ sessionID, inboxID: alreadySteered.id })
       expect(wakeCalls).toEqual([])
-      expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
-      expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
+      expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxDeliveryChanged.type, 1))).toBe(2)
+      expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxCancelled.type, 1))).toBe(1)
     }),
   )
 })

+ 2 - 2
packages/core/test/session-runner-recorded.test.ts

@@ -243,9 +243,9 @@ describe("SessionRunnerLLM recorded", () => {
           .orderBy(EventTable.seq)
           .all()).map((event) => event.type),
       ).toEqual([
-        "session.input.admitted.1",
+        "session.inbox.enqueued.1",
         "session.instructions.updated.2",
-        "session.input.promoted.1",
+        "session.inbox.delivered.1",
         "session.step.started.1",
         "session.text.started.1",
         "session.text.ended.1",

+ 64 - 29
packages/core/test/session-runner.test.ts

@@ -32,7 +32,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
 import { Session } from "@opencode-ai/core/session"
 import { Snapshot } from "@opencode-ai/core/snapshot"
 import { SessionEvent } from "@opencode-ai/core/session/event"
-import { SessionPending } from "@opencode-ai/core/session/pending"
+import { SessionInbox } from "@opencode-ai/core/session/inbox"
 import { SessionMessage } from "@opencode-ai/core/session/message"
 import { Money } from "@opencode-ai/schema/money"
 import { SessionProjector } from "@opencode-ai/core/session/projector"
@@ -55,7 +55,7 @@ import { Tool } from "@opencode-ai/core/tool"
 import type { Info as ToolInfo } from "@opencode-ai/schema/tool"
 import {
   InstructionStateTable,
-  SessionPendingTable,
+  SessionInboxTable,
   SessionMessageTable,
   SessionTable,
 } from "@opencode-ai/core/session/sql"
@@ -72,7 +72,7 @@ import { Location } from "@opencode-ai/core/location"
 import { Provider } from "@opencode-ai/core/provider"
 import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect"
 import { TestClock } from "effect/testing"
-import { asc, eq } from "drizzle-orm"
+import { asc, desc, eq } from "drizzle-orm"
 import { testEffect } from "./lib/effect"
 import { agentHost, catalogHost, host } from "./plugin/host"
 import PROMPT_DEFAULT from "../src/session/runner/prompt/base.txt"
@@ -629,7 +629,7 @@ const replaySessionProjection = (id: Session.ID) =>
 
     yield* bus.remove(id)
     yield* db.delete(InstructionStateTable).where(eq(InstructionStateTable.session_id, id)).run().pipe(Effect.orDie)
-    yield* db.delete(SessionPendingTable).where(eq(SessionPendingTable.session_id, id)).run().pipe(Effect.orDie)
+    yield* db.delete(SessionInboxTable).where(eq(SessionInboxTable.session_id, id)).run().pipe(Effect.orDie)
     yield* db.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, id)).run().pipe(Effect.orDie)
     yield* bus.replayAll(
       recorded.map((event) => ({
@@ -1186,7 +1186,7 @@ describe("SessionRunnerLLM", () => {
       expect(Exit.isFailure(exit)).toBe(true)
       if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Instructions.InitializationBlocked)
       expect(requests).toHaveLength(0)
-      expect(yield* SessionPending.has(db, sessionID, "steer")).toBe(true)
+      expect(yield* SessionInbox.has(db, sessionID, "steer")).toBe(true)
       expect(
         yield* db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).get(),
       ).toBeUndefined()
@@ -1210,6 +1210,7 @@ describe("SessionRunnerLLM", () => {
       yield* bus.publish(SessionEvent.Moved, {
         sessionID,
         location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
+        projectID: Project.ID.global,
       })
       expect(
         yield* db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).get(),
@@ -1220,7 +1221,43 @@ describe("SessionRunnerLLM", () => {
 
       expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
       expect(requests).toHaveLength(1)
-      expect(yield* SessionPending.has(db, sessionID, "steer")).toBe(true)
+      expect(yield* SessionInbox.has(db, sessionID, "steer")).toBe(true)
+    }),
+  )
+
+  it.effect("delivers a queued move atomically at the idle boundary", () =>
+    Effect.gen(function* () {
+      const session = yield* setup
+      const bus = yield* Bus.Service
+      const { db } = yield* Database.Service
+      const inboxID = SessionMessage.ID.create()
+      yield* SessionInbox.admit(db, bus, {
+        id: inboxID,
+        sessionID,
+        item: {
+          type: "move",
+          payload: {
+            location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
+            projectID: Project.ID.global,
+          },
+          delivery: "queue",
+        },
+      })
+
+      yield* session.resume(sessionID)
+
+      expect((yield* session.get(sessionID)).location.directory).toBe(AbsolutePath.make("/moved"))
+      expect(yield* session.inbox(sessionID)).toEqual([])
+      expect(requests).toEqual([])
+      expect(
+        (yield* db
+          .select({ type: EventTable.type })
+          .from(EventTable)
+          .where(eq(EventTable.aggregate_id, sessionID))
+          .orderBy(desc(EventTable.seq))
+          .limit(2)
+          .all()).map((event) => event.type),
+      ).toEqual([Bus.versionedType(SessionEvent.Moved.type, 1), Bus.versionedType(SessionEvent.InboxDelivered.type, 1)])
     }),
   )
 
@@ -1825,15 +1862,15 @@ describe("SessionRunnerLLM", () => {
     }),
   )
 
-  it.effect("runs one durable compaction barrier after tool settlement and before later inputs", () =>
+  it.effect("runs steers before queued compaction and later queued input", () =>
     Effect.gen(function* () {
       const session = yield* setup
       currentModel = recoveryModel
       const stream = yield* TestLLM.gate
       yield* TestLLM.push(
         TestLLM.tool("call-active", "echo", { text: "active" }),
-        [LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
         TestLLM.text("Steer complete", "text-steer"),
+        [LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
         TestLLM.text("Queue complete", "text-queue"),
       )
       yield* admit(session, "Active work")
@@ -1841,9 +1878,7 @@ describe("SessionRunnerLLM", () => {
       yield* stream.started
 
       const first = yield* session.compact({ sessionID })
-      const second = yield* session.compact({ sessionID })
-      expect(second.id).toBe(first.id)
-      expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toMatchObject({
+      expect(yield* SessionInbox.find((yield* Database.Service).db, first.id)).toMatchObject({
         id: first.id,
       })
       expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toBeUndefined()
@@ -1856,17 +1891,17 @@ describe("SessionRunnerLLM", () => {
         delivery: "queue",
         resume: false,
       })
-      expect(yield* SessionPending.has((yield* Database.Service).db, sessionID, "steer")).toBe(false)
+      expect(yield* SessionInbox.has((yield* Database.Service).db, sessionID, "steer")).toBe(true)
 
       yield* stream.release
       yield* Fiber.join(active)
 
       expect(requests).toHaveLength(4)
-      expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary")
-      expect(userTexts(requests[2])).toContain("Steer after compaction")
-      expect(userTexts(requests[2])).toContain("Completion after compaction")
+      expect(userTexts(requests[1])).toContain("Steer after compaction")
+      expect(userTexts(requests[1])).toContain("Completion after compaction")
+      expect(userTexts(requests[2])[0]).toContain("Create a new anchored summary")
       expect(userTexts(requests[3])).toContain("Queue after compaction")
-      expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
+      expect(yield* SessionInbox.find((yield* Database.Service).db, first.id)).toBeUndefined()
       expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({
         type: "compaction",
         status: "completed",
@@ -1901,7 +1936,7 @@ describe("SessionRunnerLLM", () => {
 
       expect(requests).toHaveLength(3)
       expect(userTexts(requests[2])).toContain("Continue after failure")
-      expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
+      expect(yield* SessionInbox.find((yield* Database.Service).db, compaction.id)).toBeUndefined()
       expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
         type: "compaction",
         status: "failed",
@@ -1923,7 +1958,7 @@ describe("SessionRunnerLLM", () => {
 
       yield* session.resume(sessionID)
 
-      expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
+      expect(yield* SessionInbox.find((yield* Database.Service).db, compaction.id)).toBeUndefined()
       expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
         type: "compaction",
         status: "failed",
@@ -1938,7 +1973,7 @@ describe("SessionRunnerLLM", () => {
     }),
   )
 
-  it.effect("manually compacts when the model has no context limit", () =>
+  it.effect("delivers steered manual compaction when the model has no context limit", () =>
     Effect.gen(function* () {
       const session = yield* setup
       yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-unknown-history"))
@@ -1946,7 +1981,7 @@ describe("SessionRunnerLLM", () => {
 
       requests.length = 0
       yield* TestLLM.push(TestLLM.text("Manual summary", "text-manual-unknown-summary"))
-      const compaction = yield* session.compact({ sessionID })
+      const compaction = yield* session.compact({ sessionID, delivery: "steer" })
       yield* session.resume(sessionID)
 
       expect(requests).toHaveLength(1)
@@ -2015,7 +2050,7 @@ describe("SessionRunnerLLM", () => {
       yield* session.interrupt(sessionID)
 
       yield* Fiber.await(run)
-      expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
+      expect(yield* SessionInbox.find((yield* Database.Service).db, compaction.id)).toBeUndefined()
       expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
         type: "compaction",
         status: "failed",
@@ -2036,7 +2071,7 @@ describe("SessionRunnerLLM", () => {
 
       expect(yield* Effect.exit(session.resume(sessionID))).toMatchObject({ _tag: "Failure" })
 
-      expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
+      expect(yield* SessionInbox.find((yield* Database.Service).db, compaction.id)).toBeUndefined()
       expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
         type: "compaction",
         status: "failed",
@@ -2864,7 +2899,7 @@ describe("SessionRunnerLLM", () => {
       yield* session.interrupt(sessionID)
       expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
       expect(requests).toHaveLength(1)
-      expect(yield* SessionPending.has(db, sessionID, "queue")).toBe(true)
+      expect(yield* SessionInbox.has(db, sessionID, "queue")).toBe(true)
       const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
       yield* stream.started
       yield* stream.release
@@ -2894,7 +2929,7 @@ describe("SessionRunnerLLM", () => {
       yield* session.interrupt(sessionID)
       expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
       expect(requests).toHaveLength(1)
-      expect(yield* SessionPending.has(db, sessionID, "steer")).toBe(true)
+      expect(yield* SessionInbox.has(db, sessionID, "steer")).toBe(true)
 
       const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
       yield* stream.started
@@ -3047,7 +3082,7 @@ describe("SessionRunnerLLM", () => {
       const session = yield* setup
       const bus = yield* Bus.Service
       yield* admit(session, "Recover interrupted tool")
-      yield* SessionPending.promote((yield* Database.Service).db, bus, sessionID, "steer")
+      yield* SessionInbox.promote((yield* Database.Service).db, bus, sessionID, "steer")
       const assistantMessageID = SessionMessage.ID.create()
       yield* bus.publish(SessionEvent.Step.Started, {
         sessionID,
@@ -3104,7 +3139,7 @@ describe("SessionRunnerLLM", () => {
       const session = yield* setup
       const bus = yield* Bus.Service
       yield* admit(session, "Recover interrupted hosted tool")
-      yield* SessionPending.promote((yield* Database.Service).db, bus, sessionID, "steer")
+      yield* SessionInbox.promote((yield* Database.Service).db, bus, sessionID, "steer")
       const assistantMessageID = SessionMessage.ID.create()
       yield* bus.publish(SessionEvent.Step.Started, {
         sessionID,
@@ -3155,7 +3190,7 @@ describe("SessionRunnerLLM", () => {
       const session = yield* setup
       const bus = yield* Bus.Service
       yield* admit(session, "Recover interrupted tool input")
-      yield* SessionPending.promote((yield* Database.Service).db, bus, sessionID, "steer")
+      yield* SessionInbox.promote((yield* Database.Service).db, bus, sessionID, "steer")
       const assistantMessageID = SessionMessage.ID.create()
       yield* bus.publish(SessionEvent.Step.Started, {
         sessionID,
@@ -3208,7 +3243,7 @@ describe("SessionRunnerLLM", () => {
       const bus = yield* Bus.Service
       const defect = new Error("fail after prompt promotion")
       let fail = true
-      yield* bus.project(SessionEvent.InputPromoted, () => (fail ? Effect.die(defect) : Effect.void))
+      yield* bus.project(SessionEvent.InboxDelivered, () => (fail ? Effect.die(defect) : Effect.void))
       yield* admit(session, "Recover promoted input")
 
       expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
@@ -3230,7 +3265,7 @@ describe("SessionRunnerLLM", () => {
       const session = yield* setup
       const bus = yield* Bus.Service
       yield* bus.listen((event) =>
-        event.type === SessionEvent.InputPromoted.type
+        event.type === SessionEvent.InboxDelivered.type
           ? Effect.die("fail after prompt promotion commits")
           : Effect.void,
       )

+ 2 - 2
packages/core/test/session-skill.test.ts

@@ -15,7 +15,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
 import { SessionMessage } from "@opencode-ai/core/session/message"
 import { SessionProjector } from "@opencode-ai/core/session/projector"
 import { SessionStore } from "@opencode-ai/core/session/store"
-import { SessionPending } from "@opencode-ai/core/session/pending"
+import { SessionInbox } from "@opencode-ai/core/session/inbox"
 import { Skill } from "@opencode-ai/core/skill"
 import { testEffect } from "./lib/effect"
 
@@ -71,7 +71,7 @@ describe("Session.skill", () => {
         skills: [{ id: Skill.ID.make("effect"), mention: { start: 20, end: 27, text: "/effect" } }],
         resume: false,
       })
-      yield* SessionPending.promote(database.db, bus, session.id, "steer")
+      yield* SessionInbox.promote(database.db, bus, session.id, "steer")
 
       expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual(
         expect.objectContaining({

+ 5 - 5
packages/core/test/session-title.test.ts

@@ -114,14 +114,14 @@ const prompt = (sessionID: Session.ID, text: string) =>
   Effect.gen(function* () {
     const bus = yield* Bus.Service
     const messageID = SessionMessage.ID.create()
-    yield* bus.publish(SessionEvent.InputAdmitted, {
+    yield* bus.publish(SessionEvent.InboxEnqueued, {
       sessionID,
-      inputID: messageID,
-      input: { type: "user", data: { text }, delivery: "steer" },
+      inboxID: messageID,
+      item: { type: "user", payload: { text }, delivery: "steer" },
     })
-    yield* bus.publish(SessionEvent.InputPromoted, {
+    yield* bus.publish(SessionEvent.InboxDelivered, {
       sessionID,
-      inputID: messageID,
+      inboxID: messageID,
     })
   })
 

+ 7 - 7
packages/core/test/shared-schema.test.ts

@@ -9,7 +9,7 @@ import { Project } from "@opencode-ai/schema/project"
 import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
 import { PermissionV1 } from "@opencode-ai/schema/permission-v1"
 import { Prompt } from "@opencode-ai/schema/prompt"
-import { SessionPending } from "@opencode-ai/schema/session-pending"
+import { SessionInbox } from "@opencode-ai/schema/session-inbox"
 import { SessionMessage } from "@opencode-ai/schema/session-message"
 import { Workspace } from "@opencode-ai/schema/workspace"
 import { Command } from "@opencode-ai/schema/command"
@@ -44,7 +44,7 @@ test("Core reuses the canonical shared schemas", async () => {
     coreProject,
     coreProvider,
     coreReference,
-    coreSessionPending,
+    coreSessionInbox,
     coreSessionMessage,
     coreSkill,
     coreSchema,
@@ -65,7 +65,7 @@ test("Core reuses the canonical shared schemas", async () => {
     import("@opencode-ai/core/project/schema"),
     import("@opencode-ai/core/provider"),
     import("@opencode-ai/core/reference"),
-    import("@opencode-ai/core/session/pending"),
+    import("@opencode-ai/core/session/inbox"),
     import("@opencode-ai/core/session/message"),
     import("@opencode-ai/core/skill"),
     import("@opencode-ai/core/schema"),
@@ -126,10 +126,10 @@ test("Core reuses the canonical shared schemas", async () => {
     [Session.ID, schemaSession.Session.ID],
     [Session.Info, schemaSession.Session.Info],
     [Session.ListAnchor, schemaSession.Session.ListAnchor],
-    [coreSessionPending.Delivery, SessionPending.Delivery],
-    [coreSessionPending.Message, SessionPending.Message],
-    [coreSessionPending.User, SessionPending.User],
-    [coreSessionPending.Synthetic, SessionPending.Synthetic],
+    [coreSessionInbox.Delivery, SessionInbox.Delivery],
+    [coreSessionInbox.Item, SessionInbox.Item],
+    [coreSessionInbox.User, SessionInbox.User],
+    [coreSessionInbox.Synthetic, SessionInbox.Synthetic],
     [coreSessionMessage.ID, SessionMessage.ID],
     [coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry],
     [coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],

+ 3 - 3
packages/core/test/tool-shell.test.ts

@@ -669,8 +669,8 @@ describe("ShellTool", () => {
         return withSession(tmp.path, (registry) =>
           Effect.gen(function* () {
             const bus = yield* Bus.Service
-            const admitted = yield* bus.subscribe(SessionEvent.InputAdmitted).pipe(
-              Stream.filter((event) => event.data.sessionID === sessionID && event.data.input.type === "synthetic"),
+            const admitted = yield* bus.subscribe(SessionEvent.InboxEnqueued).pipe(
+              Stream.filter((event) => event.data.sessionID === sessionID && event.data.item.type === "synthetic"),
               Stream.runHead,
               Effect.forkScoped({ startImmediately: true }),
             )
@@ -684,7 +684,7 @@ describe("ShellTool", () => {
             const id = ShellSchema.ID.make(shellID)
             expect((yield* shell.list()).map((info) => info.id)).toContain(id)
             expect((yield* shell.wait(id)).status).toBe("timeout")
-            expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.input.data).toMatchObject({
+            expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.item.payload).toMatchObject({
               description: idleCommand,
               metadata: {
                 source: "shell",

+ 9 - 7
packages/core/test/tool-subagent.test.ts

@@ -19,7 +19,7 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
 import { Session } from "@opencode-ai/core/session"
 import { SessionEvent } from "@opencode-ai/core/session/event"
 import { SessionExecution } from "@opencode-ai/core/session/execution"
-import { SessionPending } from "@opencode-ai/core/session/pending"
+import { SessionInbox } from "@opencode-ai/core/session/inbox"
 import { SessionMessage } from "@opencode-ai/core/session/message"
 import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
 import { SessionStore } from "@opencode-ai/core/session/store"
@@ -304,7 +304,7 @@ describe("SubagentTool", () => {
             agent: "reviewer",
             model: childModel,
           })
-          expect((yield* sessions.pending(child.id)).find((message) => message.type === "user")?.data.text).toBe(
+          expect((yield* sessions.inbox(child.id)).find((message) => message.type === "user")?.payload.text).toBe(
             "You are a subagent spawned by another session.\nreview this",
           )
 
@@ -376,8 +376,8 @@ describe("SubagentTool", () => {
           const locations = yield* LocationServiceMap.Service
           const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
           const bus = yield* Bus.Service
-          const admitted = yield* bus.subscribe(SessionEvent.InputAdmitted).pipe(
-            Stream.filter((event) => event.data.sessionID === parent.id && event.data.input.type === "synthetic"),
+          const admitted = yield* bus.subscribe(SessionEvent.InboxEnqueued).pipe(
+            Stream.filter((event) => event.data.sessionID === parent.id && event.data.item.type === "synthetic"),
             Stream.take(1),
             Stream.runCollect,
             Effect.forkScoped({ startImmediately: true }),
@@ -401,8 +401,10 @@ describe("SubagentTool", () => {
           expect(settled.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }])
 
           const admission = Array.from(yield* Fiber.join(admitted))[0]
-          expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
-          expect(admission?.data.input.data).toMatchObject({
+          expect(admission?.data.item.type).toBe("synthetic")
+          if (admission?.data.item.type !== "synthetic") return yield* Effect.die("Expected synthetic inbox item")
+          expect(admission?.data.item.payload.text).toContain(`<subagent id="${childID}" state="completed"`)
+          expect(admission?.data.item.payload).toMatchObject({
             description: "background review",
             metadata: {
               source: "subagent",
@@ -412,7 +414,7 @@ describe("SubagentTool", () => {
             },
           })
           const database = yield* Database.Service
-          yield* SessionPending.promote(database.db, bus, parent.id, "steer")
+          yield* SessionInbox.promote(database.db, bus, parent.id, "steer")
           const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
           expect(synthetic).toHaveLength(1)
           expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)

+ 2 - 2
packages/plugin/src/tui/context.ts

@@ -16,7 +16,7 @@ import type {
   ReferenceInfo,
   SessionInfo,
   SessionMessageInfo,
-  SessionPendingInfo,
+  SessionInboxInfo,
   ShellInfo,
   SkillInfo,
   VcsInfo,
@@ -70,7 +70,7 @@ export interface Data {
     cost(sessionID: string): number
     status(sessionID: string): "idle" | "running"
     readonly pending: {
-      list(sessionID: string): SessionPendingInfo[]
+      list(sessionID: string): SessionInboxInfo[]
       sync(sessionID: string): Promise<void>
       invalidate(sessionID: string): void
     }

Файловите разлики са ограничени, защото са твърде много
+ 299 - 106
packages/protocol/openapi.json


+ 35 - 32
packages/protocol/src/groups/session.ts

@@ -1,6 +1,6 @@
 import { SessionMessage } from "@opencode-ai/schema/session-message"
 import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
-import { SessionPending } from "@opencode-ai/schema/session-pending"
+import { SessionInbox } from "@opencode-ai/schema/session-inbox"
 import { PromptInput } from "@opencode-ai/schema/prompt-input"
 import { Session } from "@opencode-ai/schema/session"
 import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
@@ -301,7 +301,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
     .add(
       HttpApiEndpoint.post("session.move", "/api/session/:sessionID/move", {
         params: { sessionID: Session.ID },
-        payload: Location.Ref,
+        payload: Schema.Struct({ ...Location.Ref.fields, delivery: SessionInbox.Delivery.pipe(Schema.optional) }),
         success: HttpApiSchema.NoContent,
         error: [SessionNotFoundError, InvalidRequestError],
       }).annotateMerge(
@@ -318,11 +318,11 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
         payload: Schema.Struct({
           id: SessionMessage.ID.pipe(Schema.optional),
           ...PromptInput.Prompt.fields,
-          metadata: SessionPending.UserData.fields.metadata,
-          delivery: SessionPending.Delivery.pipe(Schema.optional),
+          metadata: SessionInbox.UserPayload.fields.metadata,
+          delivery: SessionInbox.Delivery.pipe(Schema.optional),
           resume: Schema.Boolean.pipe(Schema.optional),
         }),
-        success: Schema.Struct({ data: SessionPending.User }),
+        success: Schema.Struct({ data: SessionInbox.User }),
         error: [ConflictError, InvalidRequestError, SessionNotFoundError],
       })
         .middleware(sessionLocationMiddleware)
@@ -346,10 +346,10 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
           files: PromptInput.Prompt.fields.files,
           agents: PromptInput.Prompt.fields.agents,
           skills: PromptInput.Prompt.fields.skills,
-          delivery: SessionPending.Delivery.pipe(Schema.optional),
+          delivery: SessionInbox.Delivery.pipe(Schema.optional),
           resume: Schema.Boolean.pipe(Schema.optional),
         }),
-        success: Schema.Struct({ data: SessionPending.User }),
+        success: Schema.Struct({ data: SessionInbox.User }),
         error: [ConflictError, InvalidRequestError, SessionNotFoundError, CommandNotFoundError, CommandEvaluationError],
       })
         .middleware(sessionLocationMiddleware)
@@ -390,10 +390,10 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
           text: Schema.String,
           description: Schema.String.pipe(Schema.optional),
           metadata: SessionMessage.Synthetic.fields.metadata,
-          delivery: SessionPending.Delivery.pipe(Schema.optional),
+          delivery: SessionInbox.Delivery.pipe(Schema.optional),
           resume: Schema.Boolean.pipe(Schema.optional),
         }),
-        success: Schema.Struct({ data: SessionPending.Synthetic }),
+        success: Schema.Struct({ data: SessionInbox.Synthetic }),
         error: [ConflictError, SessionNotFoundError],
       })
         .middleware(sessionLocationMiddleware)
@@ -428,8 +428,11 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
     .add(
       HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", {
         params: { sessionID: Session.ID },
-        payload: Schema.Struct({ id: SessionMessage.ID.pipe(Schema.optional) }),
-        success: Schema.Struct({ data: SessionPending.Compaction }),
+        payload: Schema.Struct({
+          id: SessionMessage.ID.pipe(Schema.optional),
+          delivery: SessionInbox.Delivery.pipe(Schema.optional),
+        }),
+        success: Schema.Struct({ data: SessionInbox.Compaction }),
         error: [ConflictError, SessionNotFoundError],
       })
         .middleware(sessionLocationMiddleware)
@@ -506,55 +509,55 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
       ),
     )
     .add(
-      HttpApiEndpoint.get("session.pending.list", "/api/session/:sessionID/pending", {
+      HttpApiEndpoint.get("session.inbox.list", "/api/session/:sessionID/inbox", {
         params: { sessionID: Session.ID },
-        success: Schema.Struct({ data: Schema.Array(SessionPending.Info) }),
+        success: Schema.Struct({ data: Schema.Array(SessionInbox.Info) }),
         error: SessionNotFoundError,
       }).annotateMerge(
         OpenApi.annotations({
-          identifier: "v2.session.pending.list",
-          summary: "List pending session work",
+          identifier: "v2.session.inbox.list",
+          summary: "List session inbox",
           description:
-            "List durable admitted session work not yet visible in projected history, ordered by admission. Includes unpromoted user and synthetic inputs and unhandled compaction barriers. The runner owns consumption; items disappear once promoted or handled.",
+            "List durable enqueued session work not yet delivered, ordered by enqueue sequence. Includes user, synthetic, compaction, and move items.",
         }),
       ),
     )
     .add(
-      HttpApiEndpoint.delete("session.pending.cancel", "/api/session/:sessionID/pending/:inputID", {
-        params: { sessionID: Session.ID, inputID: SessionMessage.ID },
+      HttpApiEndpoint.delete("session.inbox.cancel", "/api/session/:sessionID/inbox/:inboxID", {
+        params: { sessionID: Session.ID, inboxID: SessionMessage.ID },
         success: HttpApiSchema.NoContent,
         error: [ConflictError, SessionNotFoundError],
       }).annotateMerge(
         OpenApi.annotations({
-          identifier: "v2.session.pending.cancel",
-          summary: "Cancel pending input",
-          description: "Cancel an input that has not yet been promoted into session history.",
+          identifier: "v2.session.inbox.cancel",
+          summary: "Cancel inbox input",
+          description: "Cancel an inbox item that has not yet been delivered.",
         }),
       ),
     )
     .add(
-      HttpApiEndpoint.post("session.pending.steer", "/api/session/:sessionID/pending/:inputID/steer", {
-        params: { sessionID: Session.ID, inputID: SessionMessage.ID },
+      HttpApiEndpoint.post("session.inbox.steer", "/api/session/:sessionID/inbox/:inboxID/steer", {
+        params: { sessionID: Session.ID, inboxID: SessionMessage.ID },
         success: HttpApiSchema.NoContent,
         error: [ConflictError, SessionNotFoundError],
       }).annotateMerge(
         OpenApi.annotations({
-          identifier: "v2.session.pending.steer",
-          summary: "Steer queued input",
-          description: "Change a queued input to steer delivery and wake session execution.",
+          identifier: "v2.session.inbox.steer",
+          summary: "Steer queued item",
+          description: "Change a queued inbox item to steer delivery and wake session execution.",
         }),
       ),
     )
     .add(
-      HttpApiEndpoint.post("session.pending.queue", "/api/session/:sessionID/pending/:inputID/queue", {
-        params: { sessionID: Session.ID, inputID: SessionMessage.ID },
+      HttpApiEndpoint.post("session.inbox.queue", "/api/session/:sessionID/inbox/:inboxID/queue", {
+        params: { sessionID: Session.ID, inboxID: SessionMessage.ID },
         success: HttpApiSchema.NoContent,
         error: [ConflictError, SessionNotFoundError],
       }).annotateMerge(
         OpenApi.annotations({
-          identifier: "v2.session.pending.queue",
-          summary: "Queue pending steer",
-          description: "Change a pending steer to queued delivery.",
+          identifier: "v2.session.inbox.queue",
+          summary: "Queue steered item",
+          description: "Change a steered inbox item to queued delivery.",
         }),
       ),
     )
@@ -657,7 +660,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
             identifier: "v2.session.interrupt",
             summary: "Interrupt session execution",
             description:
-              "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable pending work remains after interruption.",
+              "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable inbox work remains after interruption.",
           }),
         ),
     )

+ 1 - 1
packages/schema/src/index.ts

@@ -22,7 +22,7 @@ export { Reference } from "./reference.js"
 export { WebSearch } from "./websearch.js"
 export { Session } from "./session.js"
 export { Vcs } from "./vcs.js"
-export { SessionPending } from "./session-pending.js"
+export { SessionInbox } from "./session-inbox.js"
 export { SessionError } from "./session-error.js"
 export { SessionMessage } from "./session-message.js"
 export { SessionTransfer } from "./session-transfer.js"

+ 0 - 6
packages/schema/src/session-delivery.ts

@@ -1,6 +0,0 @@
-export * as SessionDelivery from "./session-delivery.js"
-
-import { Schema } from "effect"
-
-export const Delivery = Schema.Literals(["steer", "queue"])
-export type Delivery = typeof Delivery.Type

+ 25 - 46
packages/schema/src/session-event.ts

@@ -20,7 +20,7 @@ import { Skill as SkillSchema } from "./skill.js"
 import { Money } from "./money.js"
 import { Snapshot } from "./snapshot.js"
 import { TokenUsage } from "./token-usage.js"
-import { SessionPending } from "./session-pending.js"
+import { SessionInbox } from "./session-inbox.js"
 import { Project } from "./project.js"
 import { SessionFork } from "./session-fork.js"
 
@@ -90,9 +90,7 @@ export const Moved = Event.durable({
   ...options,
   schema: {
     ...Base,
-    location: Location.Ref,
-    projectID: Project.ID.pipe(optional),
-    subpath: RelativePath.pipe(optional),
+    ...SessionInbox.MovePayload.fields,
   },
 })
 export type Moved = typeof Moved.Type
@@ -154,48 +152,41 @@ export const Forked = Event.durable({
 })
 export type Forked = typeof Forked.Type
 
-const InputRef = {
+const InboxRef = {
   ...Base,
-  inputID: SessionMessage.ID,
+  inboxID: SessionMessage.ID,
 }
 
-export const InputPromoted = Event.durable({
-  type: "session.input.promoted",
+export const InboxDelivered = Event.durable({
+  type: "session.inbox.delivered",
   ...options,
-  schema: InputRef,
+  schema: InboxRef,
 })
-export type InputPromoted = typeof InputPromoted.Type
+export type InboxDelivered = typeof InboxDelivered.Type
 
-export const InputAdmitted = Event.durable({
-  type: "session.input.admitted",
+export const InboxEnqueued = Event.durable({
+  type: "session.inbox.enqueued",
   ...options,
   schema: {
-    ...InputRef,
-    input: SessionPending.Message,
+    ...InboxRef,
+    item: SessionInbox.Item,
   },
 })
-export type InputAdmitted = typeof InputAdmitted.Type
-
-export const InputCancelled = Event.durable({
-  type: "session.input.cancelled",
-  ...options,
-  schema: InputRef,
-})
-export type InputCancelled = typeof InputCancelled.Type
+export type InboxEnqueued = typeof InboxEnqueued.Type
 
-export const InputSteered = Event.durable({
-  type: "session.input.steered",
+export const InboxCancelled = Event.durable({
+  type: "session.inbox.cancelled",
   ...options,
-  schema: InputRef,
+  schema: InboxRef,
 })
-export type InputSteered = typeof InputSteered.Type
+export type InboxCancelled = typeof InboxCancelled.Type
 
-export const InputQueued = Event.durable({
-  type: "session.input.queued",
+export const InboxDeliveryChanged = Event.durable({
+  type: "session.inbox.delivery.changed",
   ...options,
-  schema: InputRef,
+  schema: { ...InboxRef, delivery: SessionInbox.Delivery },
 })
-export type InputQueued = typeof InputQueued.Type
+export type InboxDeliveryChanged = typeof InboxDeliveryChanged.Type
 
 export namespace Execution {
   export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
@@ -523,16 +514,6 @@ export const RetryScheduled = Event.durable({
 export type RetryScheduled = typeof RetryScheduled.Type
 
 export namespace Compaction {
-  export const Admitted = Event.durable({
-    type: "session.compaction.admitted",
-    ...options,
-    schema: {
-      ...Base,
-      inputID: SessionMessage.ID,
-    },
-  })
-  export type Admitted = typeof Admitted.Type
-
   export const Started = Event.durable({
     type: "session.compaction.started",
     ...options,
@@ -602,11 +583,10 @@ export const Definitions = Event.inventory(
   UsageUpdated,
   Deleted,
   Forked,
-  InputPromoted,
-  InputAdmitted,
-  InputCancelled,
-  InputSteered,
-  InputQueued,
+  InboxDelivered,
+  InboxEnqueued,
+  InboxCancelled,
+  InboxDeliveryChanged,
   Execution.Started,
   Execution.Succeeded,
   Execution.Failed,
@@ -633,7 +613,6 @@ export const Definitions = Event.inventory(
   Tool.Success,
   Tool.Failed,
   RetryScheduled,
-  Compaction.Admitted,
   Compaction.Started,
   Compaction.Delta,
   Compaction.Ended,

+ 78 - 0
packages/schema/src/session-inbox.ts

@@ -0,0 +1,78 @@
+export * as SessionInbox from "./session-inbox.js"
+
+import { Schema } from "effect"
+import { Location } from "./location.js"
+import { Project } from "./project.js"
+import { Prompt } from "./prompt.js"
+import { DateTimeUtcFromMillis, optional, RelativePath } from "./schema.js"
+import { SessionID } from "./session-id.js"
+import { SessionMessage } from "./session-message.js"
+
+export const Delivery = Schema.Literals(["steer", "queue"]).annotate({ identifier: "Session.Inbox.Delivery" })
+export type Delivery = typeof Delivery.Type
+
+export interface UserPayload extends Schema.Schema.Type<typeof UserPayload> {}
+export const UserPayload = Schema.Struct({
+  ...Prompt.fields,
+  metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
+}).annotate({ identifier: "Session.Inbox.UserPayload" })
+
+export interface SyntheticPayload extends Schema.Schema.Type<typeof SyntheticPayload> {}
+export const SyntheticPayload = Schema.Struct({
+  text: Schema.String,
+  description: Schema.String.pipe(optional),
+  metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
+}).annotate({ identifier: "Session.Inbox.SyntheticPayload" })
+
+export interface CompactionPayload extends Schema.Schema.Type<typeof CompactionPayload> {}
+export const CompactionPayload = Schema.Struct({}).annotate({ identifier: "Session.Inbox.CompactionPayload" })
+
+export interface MovePayload extends Schema.Schema.Type<typeof MovePayload> {}
+export const MovePayload = Schema.Struct({
+  location: Location.Ref,
+  projectID: Project.ID,
+  subpath: RelativePath.pipe(optional),
+}).annotate({ identifier: "Session.Inbox.MovePayload" })
+
+const UserItem = Schema.Struct({ type: Schema.tag("user"), payload: UserPayload, delivery: Delivery })
+const SyntheticItem = Schema.Struct({ type: Schema.tag("synthetic"), payload: SyntheticPayload, delivery: Delivery })
+const CompactionItem = Schema.Struct({
+  type: Schema.tag("compaction"),
+  payload: CompactionPayload,
+  delivery: Delivery,
+})
+const MoveItem = Schema.Struct({ type: Schema.tag("move"), payload: MovePayload, delivery: Delivery })
+
+export const Item = Schema.Union([UserItem, SyntheticItem, CompactionItem, MoveItem]).pipe(
+  Schema.toTaggedUnion("type"),
+  Schema.annotate({ identifier: "Session.Inbox.Item" }),
+)
+export type Item = typeof Item.Type
+
+const Enqueued = {
+  id: SessionMessage.ID,
+  sessionID: SessionID,
+  timeCreated: DateTimeUtcFromMillis,
+}
+
+export interface User extends Schema.Schema.Type<typeof User> {}
+export const User = Schema.Struct({ ...Enqueued, ...UserItem.fields }).annotate({ identifier: "Session.Inbox.User" })
+
+export interface Synthetic extends Schema.Schema.Type<typeof Synthetic> {}
+export const Synthetic = Schema.Struct({ ...Enqueued, ...SyntheticItem.fields }).annotate({
+  identifier: "Session.Inbox.Synthetic",
+})
+
+export interface Compaction extends Schema.Schema.Type<typeof Compaction> {}
+export const Compaction = Schema.Struct({ ...Enqueued, ...CompactionItem.fields }).annotate({
+  identifier: "Session.Inbox.Compaction",
+})
+
+export interface Move extends Schema.Schema.Type<typeof Move> {}
+export const Move = Schema.Struct({ ...Enqueued, ...MoveItem.fields }).annotate({ identifier: "Session.Inbox.Move" })
+
+export const Info = Schema.Union([User, Synthetic, Compaction, Move]).pipe(
+  Schema.toTaggedUnion("type"),
+  Schema.annotate({ identifier: "Session.Inbox.Info" }),
+)
+export type Info = typeof Info.Type

+ 0 - 75
packages/schema/src/session-pending.ts

@@ -1,75 +0,0 @@
-export * as SessionPending from "./session-pending.js"
-
-import { Schema } from "effect"
-import { optional } from "./schema.js"
-import { Prompt } from "./prompt.js"
-import { DateTimeUtcFromMillis } from "./schema.js"
-import { SessionDelivery } from "./session-delivery.js"
-import { SessionID } from "./session-id.js"
-import { SessionMessage } from "./session-message.js"
-
-export const Delivery = SessionDelivery.Delivery
-export type Delivery = SessionDelivery.Delivery
-
-export interface UserData extends Schema.Schema.Type<typeof UserData> {}
-export const UserData = Schema.Struct({
-  ...Prompt.fields,
-  metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
-}).annotate({ identifier: "SessionPending.UserData" })
-
-export interface SyntheticData extends Schema.Schema.Type<typeof SyntheticData> {}
-export const SyntheticData = Schema.Struct({
-  text: Schema.String,
-  description: Schema.String.pipe(optional),
-  metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
-}).annotate({ identifier: "SessionPending.SyntheticData" })
-
-export interface UserMessage extends Schema.Schema.Type<typeof UserMessage> {}
-export const UserMessage = Schema.Struct({
-  type: Schema.tag("user"),
-  data: UserData,
-  delivery: Delivery,
-}).annotate({ identifier: "SessionPending.UserMessage" })
-
-export interface SyntheticMessage extends Schema.Schema.Type<typeof SyntheticMessage> {}
-export const SyntheticMessage = Schema.Struct({
-  type: Schema.tag("synthetic"),
-  data: SyntheticData,
-  delivery: Delivery,
-}).annotate({ identifier: "SessionPending.SyntheticMessage" })
-
-export const Message = Schema.Union([UserMessage, SyntheticMessage]).pipe(
-  Schema.toTaggedUnion("type"),
-  Schema.annotate({ identifier: "SessionPending.Message" }),
-)
-export type Message = typeof Message.Type
-
-const Admitted = {
-  id: SessionMessage.ID,
-  sessionID: SessionID,
-  timeCreated: DateTimeUtcFromMillis,
-}
-
-export interface User extends Schema.Schema.Type<typeof User> {}
-export const User = Schema.Struct({
-  ...Admitted,
-  ...UserMessage.fields,
-}).annotate({ identifier: "SessionPending.User" })
-
-export interface Synthetic extends Schema.Schema.Type<typeof Synthetic> {}
-export const Synthetic = Schema.Struct({
-  ...Admitted,
-  ...SyntheticMessage.fields,
-}).annotate({ identifier: "SessionPending.Synthetic" })
-
-export interface Compaction extends Schema.Schema.Type<typeof Compaction> {}
-export const Compaction = Schema.Struct({
-  ...Admitted,
-  type: Schema.tag("compaction"),
-}).annotate({ identifier: "SessionPending.Compaction" })
-
-export const Info = Schema.Union([User, Synthetic, Compaction]).pipe(
-  Schema.toTaggedUnion("type"),
-  Schema.annotate({ identifier: "SessionPending.Info" }),
-)
-export type Info = typeof Info.Type

+ 34 - 12
packages/schema/test/contract-hygiene.test.ts

@@ -11,7 +11,7 @@ import { Pty } from "../src/pty.js"
 import { Question } from "../src/question.js"
 import { Session } from "../src/session.js"
 import { SessionMessage } from "../src/session-message.js"
-import { SessionPending } from "../src/session-pending.js"
+import { SessionInbox } from "../src/session-inbox.js"
 import { FileDiff } from "../src/file-diff.js"
 import { Money } from "../src/money.js"
 import { Skill } from "../src/skill.js"
@@ -47,7 +47,7 @@ describe("contract hygiene", () => {
     expect(Schema.encodeSync(Value)({ value: 1 })).toEqual({ value: "1" })
     expect(Schema.encodeSync(Value)({ value: undefined })).toEqual({})
     expect(
-      Schema.encodeSync(SessionPending.SyntheticData)({
+      Schema.encodeSync(SessionInbox.SyntheticPayload)({
         text: "completed",
         description: undefined,
         metadata: undefined,
@@ -67,16 +67,16 @@ describe("contract hygiene", () => {
     ).not.toHaveProperty("title")
   })
 
-  test("pending session items omit the internal admission sequence", () => {
+  test("session inbox items omit the internal enqueue sequence", () => {
     expect(
-      Schema.encodeSync(SessionPending.Info)(
-        Schema.decodeUnknownSync(SessionPending.Info)({
+      Schema.encodeSync(SessionInbox.Info)(
+        Schema.decodeUnknownSync(SessionInbox.Info)({
           admittedSeq: 3,
           id: "msg_pending",
           sessionID: "ses_pending",
           timeCreated: 1,
           type: "user",
-          data: { text: "hello" },
+          payload: { text: "hello" },
           delivery: "steer",
         }),
       ),
@@ -85,7 +85,7 @@ describe("contract hygiene", () => {
       sessionID: "ses_pending",
       timeCreated: 1,
       type: "user",
-      data: { text: "hello" },
+      payload: { text: "hello" },
       delivery: "steer",
     })
   })
@@ -167,10 +167,17 @@ describe("contract hygiene", () => {
       Pty.Info,
       Session.ListAnchor,
       Session.Revert,
-      SessionPending.UserData,
-      SessionPending.SyntheticData,
-      SessionPending.User,
-      SessionPending.Synthetic,
+      SessionInbox.Delivery,
+      SessionInbox.UserPayload,
+      SessionInbox.SyntheticPayload,
+      SessionInbox.CompactionPayload,
+      SessionInbox.MovePayload,
+      SessionInbox.Item,
+      SessionInbox.User,
+      SessionInbox.Synthetic,
+      SessionInbox.Compaction,
+      SessionInbox.Move,
+      SessionInbox.Info,
       Vcs.Branch,
       Vcs.Info,
     ].map((schema) => schema.ast.annotations?.identifier)
@@ -179,6 +186,21 @@ describe("contract hygiene", () => {
     expect(new Set(identifiers).size).toBe(identifiers.length)
   })
 
+  test("all session inbox item types accept both delivery modes", () => {
+    const decode = Schema.decodeUnknownSync(SessionInbox.Info)
+    const base = { id: "msg_inbox", sessionID: "ses_inbox", timeCreated: 1 }
+    const move = {
+      location: { directory: "/project" },
+      projectID: "global",
+    }
+    for (const delivery of ["steer", "queue"] as const) {
+      expect(decode({ ...base, type: "user", payload: { text: "hello" }, delivery }).delivery).toBe(delivery)
+      expect(decode({ ...base, type: "synthetic", payload: { text: "context" }, delivery }).delivery).toBe(delivery)
+      expect(decode({ ...base, type: "compaction", payload: {}, delivery }).delivery).toBe(delivery)
+      expect(decode({ ...base, type: "move", payload: move, delivery }).delivery).toBe(delivery)
+    }
+  })
+
   test("current source limits Any to provider options and avoids mutable contract wrappers", async () => {
     const files = [...new Bun.Glob("*.ts").scanSync(new URL("../src", import.meta.url).pathname)].filter(
       (file) => !file.endsWith("-v1.ts"),
@@ -221,7 +243,7 @@ describe("contract hygiene", () => {
 
   test("reviewed session contracts use their canonical current shapes", () => {
     expect(SessionMessage.Info.ast.annotations?.identifier).toBe("Session.Message.Info")
-    expect(SessionPending.Info.ast.annotations?.identifier).toBe("SessionPending.Info")
+    expect(SessionInbox.Info.ast.annotations?.identifier).toBe("Session.Inbox.Info")
     expect(Money.USD).not.toBe(Money.USDPerMillionTokens)
     expect(
       FileDiff.Info.make({ file: "src/index.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }),

+ 4 - 6
packages/schema/test/event-manifest.test.ts

@@ -82,11 +82,10 @@ describe("public event manifest", () => {
         "session.renamed.1",
         "session.usage.recorded.1",
         "session.forked.2",
-        "session.input.promoted.1",
-        "session.input.admitted.1",
-        "session.input.cancelled.1",
-        "session.input.steered.1",
-        "session.input.queued.1",
+        "session.inbox.delivered.1",
+        "session.inbox.enqueued.1",
+        "session.inbox.cancelled.1",
+        "session.inbox.delivery.changed.1",
         "session.execution.started.1",
         "session.execution.succeeded.1",
         "session.execution.failed.1",
@@ -109,7 +108,6 @@ describe("public event manifest", () => {
         "session.reasoning.started.1",
         "session.reasoning.ended.1",
         "session.retry.scheduled.1",
-        "session.compaction.admitted.1",
         "session.compaction.started.1",
         "session.compaction.ended.1",
         "session.compaction.failed.1",

+ 1 - 1
packages/sdk-next/src/index.ts

@@ -24,6 +24,6 @@ export { Reference } from "@opencode-ai/schema/reference"
 export { WebSearch } from "@opencode-ai/schema/websearch"
 export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
 export { Session } from "@opencode-ai/schema/session"
-export { SessionPending } from "@opencode-ai/schema/session-pending"
+export { SessionInbox } from "@opencode-ai/schema/session-inbox"
 export { SessionMessage } from "@opencode-ai/schema/session-message"
 export { Skill } from "@opencode-ai/schema/skill"

+ 6 - 6
packages/sdk-next/test/contract-identity.test.ts

@@ -1,6 +1,6 @@
 import { expect, test } from "bun:test"
 import { Location as CoreLocation } from "@opencode-ai/core/location"
-import { SessionPending as CoreSessionPending } from "@opencode-ai/core/session/pending"
+import { SessionInbox as CoreSessionInbox } from "@opencode-ai/core/session/inbox"
 import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
 import { Agent } from "@opencode-ai/schema/agent"
 import { Config } from "@opencode-ai/schema/config"
@@ -10,7 +10,7 @@ import { Project } from "@opencode-ai/schema/project"
 import { Provider } from "@opencode-ai/schema/provider"
 import { WebSearch } from "@opencode-ai/schema/websearch"
 import { Session } from "@opencode-ai/schema/session"
-import { SessionPending } from "@opencode-ai/schema/session-pending"
+import { SessionInbox } from "@opencode-ai/schema/session-inbox"
 import { SessionMessage } from "@opencode-ai/schema/session-message"
 import { Workspace } from "@opencode-ai/schema/workspace"
 import { Api } from "@opencode-ai/server/api"
@@ -53,8 +53,8 @@ test("re-exports canonical contracts directly from Schema", () => {
     "Reference",
     "RelativePath",
     "Session",
+    "SessionInbox",
     "SessionMessage",
-    "SessionPending",
     "Skill",
     "Tool",
     "WebSearch",
@@ -69,9 +69,9 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
   expect(CoreProject.Current).toBe(Project.Current)
   expect(CoreProject.Directory).toBe(Project.Directory)
   expect(CoreProject.Directories).toBe(Project.Directories)
-  expect(CoreSessionPending.Message).toBe(SessionPending.Message)
-  expect(CoreSessionPending.User).toBe(SessionPending.User)
-  expect(CoreSessionPending.Synthetic).toBe(SessionPending.Synthetic)
+  expect(CoreSessionInbox.Item).toBe(SessionInbox.Item)
+  expect(CoreSessionInbox.User).toBe(SessionInbox.User)
+  expect(CoreSessionInbox.Synthetic).toBe(SessionInbox.Synthetic)
   expect(CoreSessionMessage.Info).toBe(SessionMessage.Info)
   expect(CoreSessionMessage.AssistantText).toBe(SessionMessage.AssistantText)
   expect(Api.groups["server.session"].identifier).toBe("server.session")

+ 7 - 7
packages/sdk-next/test/embedded.test.ts

@@ -241,7 +241,7 @@ it.live(
           resume: false,
         })
         const context = yield* opencode.sessions.context({ sessionID: id })
-        const pendingAfterAdmit = yield* opencode.sessions.pending.list({ sessionID: id })
+        const pendingAfterAdmit = yield* opencode.sessions.inbox.list({ sessionID: id })
         yield* opencode.sessions.instructions.entry.put({ sessionID: id, key: "deploy-target", value: "production" })
         yield* opencode.sessions.instructions.entry.put({ sessionID: id, key: "flags", value: { beta: true } })
         const contextEntries = yield* opencode.sessions.instructions.entry.list({ sessionID: id })
@@ -252,13 +252,13 @@ it.live(
           text: "Promote this input",
         })
         const prompted = yield* opencode.sessions.log({ sessionID: id, follow: true }).pipe(
-          Stream.filter((event) => event.type === "session.input.promoted" && event.data.inputID === wake.id),
+          Stream.filter((event) => event.type === "session.inbox.delivered" && event.data.inboxID === wake.id),
           Stream.runHead,
           Effect.timeout("10 seconds"),
           Effect.map(Option.getOrThrow),
         )
         const wakeContext = yield* opencode.sessions.context({ sessionID: id })
-        const pendingAfterPromote = yield* opencode.sessions.pending.list({ sessionID: id })
+        const pendingAfterPromote = yield* opencode.sessions.inbox.list({ sessionID: id })
         const event = yield* opencode.sessions.log({ sessionID: id }).pipe(
           Stream.filter((item) => item.type === "session.model.selected"),
           Stream.take(1),
@@ -278,7 +278,7 @@ it.live(
             opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip),
             opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip),
             opencode.sessions.instructions.entry.list({ sessionID: missingSessionID }).pipe(Effect.flip),
-            opencode.sessions.pending.list({ sessionID: missingSessionID }).pipe(Effect.flip),
+            opencode.sessions.inbox.list({ sessionID: missingSessionID }).pipe(Effect.flip),
           ],
           { concurrency: "unbounded" },
         )
@@ -298,7 +298,7 @@ it.live(
         expect(pendingAfterAdmit).toContainEqual(
           expect.objectContaining({ id: admitted.id, type: "user", delivery: "steer" }),
         )
-        expect(prompted.type).toBe("session.input.promoted")
+        expect(prompted.type).toBe("session.inbox.delivered")
         expect(pendingAfterPromote.map((item) => item.id)).not.toContainAnyValues([admitted.id, wake.id])
         expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" }))
         expect(contextEntries).toEqual([
@@ -362,13 +362,13 @@ it.live(
         const opencode = yield* fixture.sdk.OpenCode.create()
         const id = sessionID(fixture)
         const connected = yield* Latch.make(false)
-        const prompted = yield* Deferred.make<Extract<OpenCodeEvent, { type: "session.input.promoted" }>>()
+        const prompted = yield* Deferred.make<Extract<OpenCodeEvent, { type: "session.inbox.delivered" }>>()
 
         yield* opencode.events.subscribe().pipe(
           Stream.runForEach((event) =>
             event.type === "server.connected"
               ? connected.open
-              : event.type === "session.input.promoted" && event.data.sessionID === id
+              : event.type === "session.inbox.delivered" && event.data.sessionID === id
                 ? Deferred.succeed(prompted, event).pipe(Effect.asVoid)
                 : Effect.void,
           ),

+ 29 - 26
packages/server/src/handlers/session.ts

@@ -26,7 +26,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
   Effect.gen(function* () {
     const session = yield* Session.Service
     const transfer = yield* SessionTransfer.Service
-    const pendingMutation = (effect: ReturnType<typeof session.cancelPending>, conflict: string) =>
+    const pendingMutation = (effect: ReturnType<typeof session.cancelInbox>, conflict: string) =>
       effect.pipe(
         Effect.catchTag(
           "Session.NotFoundError",
@@ -37,8 +37,8 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
             }),
         ),
         Effect.catchTag(
-          "Session.PendingInputConflictError",
-          (error) => new ConflictError({ resource: error.inputID, message: `${conflict}: ${error.inputID}` }),
+          "Session.InboxConflictError",
+          (error) => new ConflictError({ resource: error.inboxID, message: `${conflict}: ${error.inboxID}` }),
         ),
         Effect.as(HttpApiSchema.NoContent.make()),
       )
@@ -282,6 +282,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
               sessionID: ctx.params.sessionID,
               directory: ctx.payload.directory,
               workspaceID: ctx.payload.workspaceID,
+              delivery: ctx.payload.delivery,
             })
             .pipe(
               Effect.catchTag("Session.NotFoundError", (error) =>
@@ -488,24 +489,26 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
         "session.compact",
         Effect.fn(function* (ctx) {
           return {
-            data: yield* session.compact({ sessionID: ctx.params.sessionID, id: ctx.payload.id }).pipe(
-              Effect.catchTag("Session.NotFoundError", (error) =>
-                Effect.fail(
-                  new SessionNotFoundError({
-                    sessionID: error.sessionID,
-                    message: `Session not found: ${error.sessionID}`,
-                  }),
+            data: yield* session
+              .compact({ sessionID: ctx.params.sessionID, id: ctx.payload.id, delivery: ctx.payload.delivery })
+              .pipe(
+                Effect.catchTag("Session.NotFoundError", (error) =>
+                  Effect.fail(
+                    new SessionNotFoundError({
+                      sessionID: error.sessionID,
+                      message: `Session not found: ${error.sessionID}`,
+                    }),
+                  ),
                 ),
-              ),
-              Effect.catchTag("Session.CompactionConflictError", (error) =>
-                Effect.fail(
-                  new ConflictError({
-                    message: `Compaction input ID conflicts with an existing durable record: ${error.inputID}`,
-                    resource: error.inputID,
-                  }),
+                Effect.catchTag("Session.CompactionConflictError", (error) =>
+                  Effect.fail(
+                    new ConflictError({
+                      message: `Compaction input ID conflicts with an existing durable record: ${error.inputID}`,
+                      resource: error.inputID,
+                    }),
+                  ),
                 ),
               ),
-            ),
           }
         }),
       )
@@ -669,10 +672,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
         }),
       )
       .handle(
-        "session.pending.list",
+        "session.inbox.list",
         Effect.fn(function* (ctx) {
           return {
-            data: yield* session.pending(ctx.params.sessionID).pipe(
+            data: yield* session.inbox(ctx.params.sessionID).pipe(
               Effect.catchTag("Session.NotFoundError", (error) =>
                 Effect.fail(
                   new SessionNotFoundError({
@@ -686,28 +689,28 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
         }),
       )
       .handle(
-        "session.pending.cancel",
+        "session.inbox.cancel",
         Effect.fn(function* (ctx) {
           return yield* pendingMutation(
-            session.cancelPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
+            session.cancelInbox({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID }),
             "Pending input can no longer be cancelled",
           )
         }),
       )
       .handle(
-        "session.pending.steer",
+        "session.inbox.steer",
         Effect.fn(function* (ctx) {
           return yield* pendingMutation(
-            session.steerPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
+            session.steerInbox({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID }),
             "Pending input is no longer queued",
           )
         }),
       )
       .handle(
-        "session.pending.queue",
+        "session.inbox.queue",
         Effect.fn(function* (ctx) {
           return yield* pendingMutation(
-            session.queuePending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
+            session.queueInbox({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID }),
             "Pending input is no longer a steer",
           )
         }),

+ 1 - 1
packages/tui/src/component/devtools-bar.tsx

@@ -182,7 +182,7 @@ export function DevToolsBar() {
                 location: sessionLocation,
                 status: data.session.status(sessionID),
                 pending: data.session.pending.list(sessionID),
-                inputIDs: data.session.input.list(sessionID),
+                inboxIDs: data.session.input.list(sessionID),
                 permissions: data.session.permission.list(sessionID) ?? [],
                 forms: data.session.form.list(sessionID) ?? [],
               }

+ 3 - 3
packages/tui/src/component/prompt/index.tsx

@@ -61,7 +61,7 @@ import { useLocation } from "../../context/location"
 import { Keymap, type KeymapCommand } from "../../context/keymap"
 import { abbreviateHome } from "../../runtime"
 import { Slot } from "../../plugin/render"
-import type { SessionPending } from "@opencode-ai/schema/session-pending"
+import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
 import {
   deduplicatePromptImages,
   preserveMentionlessPromptAttachments,
@@ -1047,7 +1047,7 @@ export function Prompt(props: PromptProps) {
   })
 
   let submitting = false
-  async function submit(delivery: SessionPending.Delivery = "steer") {
+  async function submit(delivery: SessionInbox.Delivery = "steer") {
     // Prevent overlapping invocations (e.g. a double-pressed Enter, or the
     // input's native onSubmit racing another dispatch). Without this guard,
     // a second call slips past the empty-input check before the first call
@@ -1063,7 +1063,7 @@ export function Prompt(props: PromptProps) {
     }
   }
 
-  async function submitInner(delivery: SessionPending.Delivery) {
+  async function submitInner(delivery: SessionInbox.Delivery) {
     // IME: double-defer may fire before onContentChange flushes the last
     // composed character (e.g. Korean hangul) to the store, so read
     // plainText directly and sync before any downstream reads.

+ 42 - 56
packages/tui/src/context/data.tsx

@@ -25,7 +25,7 @@ import type {
   SessionMessageAssistantText,
   SessionMessageAssistantTool,
   SessionInfo,
-  SessionPendingInfo,
+  SessionInboxInfo,
   ShellInfo,
   SkillInfo,
   VcsInfo,
@@ -38,7 +38,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
 import { createSimpleContext } from "./helper"
 import { useClient } from "./client"
 import { nonEmptyToolContent } from "../util/tool-display"
-import type { SessionPending } from "@opencode-ai/schema/session-pending"
+import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
 import { createEffect, createSignal, onCleanup } from "solid-js"
 
 export type DataSessionStatus = "idle" | "running"
@@ -80,7 +80,7 @@ type Store = {
     family: Record<string, string[]>
     active: Record<string, DataSessionStatus>
     message: Record<string, SessionMessageInfo[]>
-    pending: Record<string, SessionPendingInfo[]>
+    pending: Record<string, SessionInboxInfo[]>
     input: Record<string, string[]>
     permission: Record<string, PermissionRequest[]>
     // Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
@@ -164,26 +164,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
       setStore("session", "active", sessionID, status)
     }
 
-    function addPending(item: SessionPendingInfo) {
+    function addPending(item: SessionInboxInfo) {
       if (store.session.pending[item.sessionID]?.some((pending) => pending.id === item.id)) return
       setStore("session", "pending", item.sessionID, [...(store.session.pending[item.sessionID] ?? []), item])
     }
 
-    function removePending(sessionID: string, inputID?: string) {
-      if (!inputID) return
-      if (store.session.pending[sessionID]?.some((item) => item.id === inputID))
+    function removePending(sessionID: string, inboxID?: string) {
+      if (!inboxID) return
+      if (store.session.pending[sessionID]?.some((item) => item.id === inboxID))
         setStore(
           "session",
           "pending",
           sessionID,
-          (store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID),
+          (store.session.pending[sessionID] ?? []).filter((item) => item.id !== inboxID),
         )
-      if (store.session.input[sessionID]?.includes(inputID))
+      if (store.session.input[sessionID]?.includes(inboxID))
         setStore(
           "session",
           "input",
           sessionID,
-          (store.session.input[sessionID] ?? []).filter((id) => id !== inputID),
+          (store.session.input[sessionID] ?? []).filter((id) => id !== inboxID),
         )
     }
 
@@ -198,10 +198,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
       )
     }
 
-    function updatePending(sessionID: string, inputID: string, delivery: SessionPending.Delivery) {
-      const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inputID) ?? -1
+    function updatePending(sessionID: string, inboxID: string, delivery: SessionInbox.Delivery) {
+      const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inboxID) ?? -1
       const item = store.session.pending[sessionID]?.[index]
-      if (index < 0 || !item || item.type === "compaction" || item.delivery === delivery) return
+      if (index < 0 || !item || item.delivery === delivery) return
       setStore("session", "pending", sessionID, index, { ...item, delivery })
     }
 
@@ -457,11 +457,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           }
           break
         }
-        case "session.input.promoted": {
-          const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inputID) ?? false
-          removePending(event.data.sessionID, event.data.inputID)
+        case "session.inbox.delivered": {
+          const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inboxID) ?? false
+          removePending(event.data.sessionID, event.data.inboxID)
           message.update(event.data.sessionID, (draft, index) => {
-            const position = index.get(event.data.inputID)
+            const position = index.get(event.data.inboxID)
             if (position === undefined) return
             const existing = draft[position]
             if (!existing || !admitted) return
@@ -472,56 +472,56 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           })
           break
         }
-        case "session.input.steered":
-          updatePending(event.data.sessionID, event.data.inputID, "steer")
+        case "session.inbox.delivery.changed":
+          updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery)
           break
-        case "session.input.queued":
-          updatePending(event.data.sessionID, event.data.inputID, "queue")
-          break
-        case "session.input.cancelled": {
-          removePending(event.data.sessionID, event.data.inputID)
-          if (messageIndex.get(event.data.sessionID)?.has(event.data.inputID))
+        case "session.inbox.cancelled": {
+          removePending(event.data.sessionID, event.data.inboxID)
+          if (messageIndex.get(event.data.sessionID)?.has(event.data.inboxID))
             message.update(event.data.sessionID, (draft, index) => {
-              const position = index.get(event.data.inputID)
+              const position = index.get(event.data.inboxID)
               if (position === undefined) return
               draft.splice(position, 1)
-              index.delete(event.data.inputID)
+              index.delete(event.data.inboxID)
               message.reindex(draft, index, position)
             })
           break
         }
-        case "session.input.admitted":
+        case "session.inbox.enqueued": {
+          const item = event.data.item
           addPending({
-            id: event.data.inputID,
+            id: event.data.inboxID,
             sessionID: event.data.sessionID,
             timeCreated: event.created,
-            ...event.data.input,
+            ...item,
           })
-          if (!store.session.input[event.data.sessionID]?.includes(event.data.inputID))
+          if (!store.session.input[event.data.sessionID]?.includes(event.data.inboxID))
             setStore("session", "input", event.data.sessionID, [
               ...(store.session.input[event.data.sessionID] ?? []),
-              event.data.inputID,
+              event.data.inboxID,
             ])
+          if (item.type !== "user" && item.type !== "synthetic") break
           message.update(event.data.sessionID, (draft, index) => {
             message.append(
               draft,
               index,
-              event.data.input.type === "user"
+              item.type === "user"
                 ? {
-                    id: event.data.inputID,
+                    id: event.data.inboxID,
                     type: "user",
-                    ...event.data.input.data,
+                    ...item.payload,
                     time: { created: event.created },
                   }
                 : {
-                    id: event.data.inputID,
+                    id: event.data.inboxID,
                     type: "synthetic",
-                    ...event.data.input.data,
+                    ...item.payload,
                     time: { created: event.created },
                   },
             )
           })
           break
+        }
         case "session.instructions.updated":
           // Mirror the projector: the initial baseline and empty-rendering deltas carry no text
           // and produce no transcript message.
@@ -781,16 +781,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
         case "session.execution.started":
           setSessionActive(event.data.sessionID, "running")
           break
-        case "session.compaction.admitted":
-          addPending({
-            id: event.data.inputID,
-            sessionID: event.data.sessionID,
-            timeCreated: event.created,
-            type: "compaction",
-          })
-          break
         case "session.compaction.started":
-          removePending(event.data.sessionID, event.data.inputID)
+          if (event.data.inputID) removePending(event.data.sessionID, event.data.inputID)
           message.update(event.data.sessionID, (draft, index) => {
             message.append(draft, index, {
               id: event.data.inputID ?? messageIDFromEvent(event.id),
@@ -843,12 +835,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           })
           break
         case "session.compaction.ended":
-          setStore(
-            "session",
-            "pending",
-            event.data.sessionID,
-            (store.session.pending[event.data.sessionID] ?? []).filter((item) => item.type !== "compaction"),
-          )
           message.update(event.data.sessionID, (draft, index) => {
             const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
             const current = draft[position]
@@ -873,7 +859,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           })
           break
         case "session.compaction.failed":
-          removePending(event.data.sessionID, event.data.inputID)
+          if (event.data.inputID) removePending(event.data.sessionID, event.data.inputID)
           message.update(event.data.sessionID, (draft, index) => {
             const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
             const current = draft[position]
@@ -1010,8 +996,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           list(sessionID: string) {
             return store.session.input[sessionID] ?? []
           },
-          has(sessionID: string, inputID: string) {
-            return store.session.input[sessionID]?.includes(inputID) ?? false
+          has(sessionID: string, inboxID: string) {
+            return store.session.input[sessionID]?.includes(inboxID) ?? false
           },
         },
         pending: {
@@ -1020,7 +1006,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           },
           sync(sessionID: string) {
             return sync.run(`session.pending:${sessionID}`, async () => {
-              const pending = await client.api.session.pending.list({ sessionID })
+              const pending = await client.api.session.inbox.list({ sessionID })
               setStore("session", "pending", sessionID, reconcile(pending))
               setStore(
                 "session",

+ 2 - 2
packages/tui/src/context/session-tabs.tsx

@@ -199,8 +199,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
     onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
     onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
     onCleanup(
-      event.on("session.input.admitted", (evt) => {
-        if (!enabled() || evt.data.input.type !== "user") return
+      event.on("session.inbox.enqueued", (evt) => {
+        if (!enabled() || evt.data.item.type !== "user") return
         const sessionID = root(evt.data.sessionID)
         if (current() === sessionID || !state().tabs.some((tab) => tab.sessionID === sessionID)) return
         setPromptPulses((pulses) => ({ ...pulses, [sessionID]: (pulses[sessionID] ?? 0) + 1 }))

+ 1 - 1
packages/tui/src/mini/footer.prompt.tsx

@@ -89,7 +89,7 @@ type PromptInput = {
   mono: Accessor<boolean>
   history?: Accessor<RunPrompt[]>
   queuedPrompts: Accessor<FooterQueuedPrompt[]>
-  onQueuedPromptSteer: (inputID: string) => Promise<boolean>
+  onQueuedPromptSteer: (inboxID: string) => Promise<boolean>
   onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
   onCycle: () => void
   onInterrupt: () => boolean

+ 1 - 1
packages/tui/src/mini/footer.ts

@@ -97,7 +97,7 @@ type RunFooterOptions = {
   onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
   onInterrupt?: () => void
   onBackground?: () => void
-  onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
+  onQueuedPromptAction?: (action: QueuedPromptAction, inboxID: string) => Promise<void>
   onEditorOpen: (input: { value: string }) => Promise<string | undefined>
   onSubagentSelect?: (sessionID: string | undefined) => void
   onSubagentInterrupt?: (sessionID: string) => void

+ 5 - 5
packages/tui/src/mini/footer.view.tsx

@@ -102,7 +102,7 @@ type RunFooterViewProps = {
   onCycle: () => void
   onInterrupt: () => boolean
   onBackground?: () => void
-  onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
+  onQueuedPromptAction?: (action: QueuedPromptAction, inboxID: string) => Promise<void>
   onEditorOpen: (input: { value: string }) => Promise<string | undefined>
   onInputClear: () => void
   onExitRequest?: () => boolean
@@ -324,11 +324,11 @@ export function RunFooterView(props: RunFooterViewProps) {
   }
 
   const runQueuedAction = createSingleFlight<string>()
-  const queuedPromptAction = async (action: QueuedPromptAction, inputID: string) => {
+  const queuedPromptAction = async (action: QueuedPromptAction, inboxID: string) => {
     const run = props.onQueuedPromptAction
     if (!run) return false
-    const result = await runQueuedAction(inputID, async () => {
-      const error = await run(action, inputID).then(
+    const result = await runQueuedAction(inboxID, async () => {
+      const error = await run(action, inboxID).then(
         () => undefined,
         (error) => error,
       )
@@ -379,7 +379,7 @@ export function RunFooterView(props: RunFooterViewProps) {
     mono: () => props.mono,
     history: props.history,
     queuedPrompts: queue,
-    onQueuedPromptSteer: (inputID) => queuedPromptAction("steer", inputID),
+    onQueuedPromptSteer: (inboxID) => queuedPromptAction("steer", inboxID),
     onSubmit: props.onSubmit,
     onCycle: props.onCycle,
     onInterrupt: props.onInterrupt,

+ 1 - 1
packages/tui/src/mini/runtime.lifecycle.ts

@@ -71,7 +71,7 @@ export type LifecycleInput = {
   onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
   onInterrupt?: () => void
   onBackground?: () => void
-  onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
+  onQueuedPromptAction?: (action: QueuedPromptAction, inboxID: string) => Promise<void>
   onSubagentSelect?: (sessionID: string | undefined) => void
   onSubagentInterrupt?: (sessionID: string) => void
 }

+ 4 - 4
packages/tui/src/mini/runtime.ts

@@ -392,14 +392,14 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
       log?.write("send.background", { sessionID: state.sessionID })
       void state.sdk.session.background({ sessionID: state.sessionID }).catch(() => {})
     },
-    onQueuedPromptAction: async (action, inputID) => {
+    onQueuedPromptAction: async (action, inboxID) => {
       if (!state.sessionID) return
-      log?.write(`send.pending.${action}`, { sessionID: state.sessionID, inputID })
+      log?.write(`send.pending.${action}`, { sessionID: state.sessionID, inboxID })
       if (action === "steer") {
-        await state.sdk.session.pending.steer({ sessionID: state.sessionID, inputID })
+        await state.sdk.session.inbox.steer({ sessionID: state.sessionID, inboxID })
         return
       }
-      await state.sdk.session.pending.cancel({ sessionID: state.sessionID, inputID })
+      await state.sdk.session.inbox.cancel({ sessionID: state.sessionID, inboxID })
     },
     onSubagentInterrupt: (sessionID) => {
       log?.write("send.subagent.interrupt", { sessionID })

+ 8 - 8
packages/tui/src/mini/stream-v2.subagent.ts

@@ -644,22 +644,22 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
   }
 
   const reduce = (child: ChildState, event: V2Event) => {
-    if (event.type === "session.input.admitted") {
-      if (event.data.input.type === "user") child.prompts.set(event.data.inputID, event.data.input.data.text)
+    if (event.type === "session.inbox.enqueued") {
+      if (event.data.item.type === "user") child.prompts.set(event.data.inboxID, event.data.item.payload.text)
       return
     }
-    if (event.type === "session.input.promoted") {
-      const prompt = child.prompts.get(event.data.inputID)
+    if (event.type === "session.inbox.delivered") {
+      const prompt = child.prompts.get(event.data.inboxID)
       if (prompt === undefined) return
-      child.prompts.delete(event.data.inputID)
-      if (userFrame(child, event.data.inputID, prompt)) {
+      child.prompts.delete(event.data.inboxID)
+      if (userFrame(child, event.data.inboxID, prompt)) {
         touch(child, event.created)
         notifyDetail(child)
       }
       return
     }
-    if (event.type === "session.input.cancelled") {
-      child.prompts.delete(event.data.inputID)
+    if (event.type === "session.inbox.cancelled") {
+      child.prompts.delete(event.data.inboxID)
       return
     }
     if (event.type === "session.step.started") {

+ 29 - 35
packages/tui/src/mini/stream-v2.transport.ts

@@ -6,7 +6,7 @@ import type {
   PermissionRequest,
   SessionMessageAssistantTool,
   SessionMessageInfo,
-  SessionPendingInfo,
+  SessionInboxInfo,
 } from "@opencode-ai/client/promise"
 import { Event } from "@opencode-ai/schema/event"
 import { SessionMessage } from "@opencode-ai/schema/session-message"
@@ -182,11 +182,11 @@ function errorMessage(error: { message?: string; _tag?: string }) {
   return error.message || error._tag || "Session execution failed"
 }
 
-function pendingPrompt(item: SessionPendingInfo): FooterQueuedPrompt | undefined {
+function pendingPrompt(item: SessionInboxInfo): FooterQueuedPrompt | undefined {
   if (item.type !== "user") return undefined
   return {
     messageID: item.id,
-    prompt: { messageID: item.id, text: item.data.text, parts: [] },
+    prompt: { messageID: item.id, text: item.payload.text, parts: [] },
     delivery: item.delivery,
   }
 }
@@ -542,7 +542,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
     input.footer.event({ type: "queued.prompts", prompts })
   }
 
-  const mergePending = (item: SessionPendingInfo) => {
+  const mergePending = (item: SessionInboxInfo) => {
     const prompt = pendingPrompt(item)
     if (!prompt || state.messageIDs.has(prompt.messageID)) return
     state.admitted.add(prompt.messageID)
@@ -839,7 +839,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
     const options = { signal: attempt.signal }
     const [projected, pending, permissions, forms, globals, active] = await Promise.all([
       projectedMessages(client, attempt.signal),
-      client.session.pending.list({ sessionID: input.sessionID }, options),
+      client.session.inbox.list({ sessionID: input.sessionID }, options),
       client.permission.list({ sessionID: input.sessionID }, options),
       client.form.list({ sessionID: input.sessionID }, options),
       input.location
@@ -927,25 +927,25 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
       input.onSessionTitle?.(event.data.title)
       return
     }
-    if (event.type === "session.input.admitted") {
-      if (event.data.input.type !== "user") return
+    if (event.type === "session.inbox.enqueued") {
+      if (event.data.item.type !== "user") return
       mergePending({
-        id: event.data.inputID,
+        id: event.data.inboxID,
         sessionID: event.data.sessionID,
         timeCreated: event.created,
-        ...event.data.input,
+        ...event.data.item,
       })
       return
     }
-    if (event.type === "session.input.promoted") {
-      const waiting = state.wait?.messageID === event.data.inputID
-      if (state.wait) promoteWait(state.wait, true, event.data.inputID)
-      state.admitted.delete(event.data.inputID)
-      const pending = state.pending.get(event.data.inputID)
-      state.pending.delete(event.data.inputID)
+    if (event.type === "session.inbox.delivered") {
+      const waiting = state.wait?.messageID === event.data.inboxID
+      if (state.wait) promoteWait(state.wait, true, event.data.inboxID)
+      state.admitted.delete(event.data.inboxID)
+      const pending = state.pending.get(event.data.inboxID)
+      state.pending.delete(event.data.inboxID)
       syncPending()
-      const visible = state.messageIDs.has(event.data.inputID)
-      if (waiting || pending) state.messageIDs.add(event.data.inputID)
+      const visible = state.messageIDs.has(event.data.inboxID)
+      if (waiting || pending) state.messageIDs.add(event.data.inboxID)
       if (!waiting && pending && !visible) {
         write([
           {
@@ -953,41 +953,35 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
             source: "system",
             text: pending.prompt.text,
             phase: "start",
-            messageID: event.data.inputID,
+            messageID: event.data.inboxID,
           },
         ])
       }
       write([], { phase: "running", status: "waiting for assistant" })
       return
     }
-    if (event.type === "session.input.steered") {
-      const pending = state.pending.get(event.data.inputID)
+    if (event.type === "session.inbox.delivery.changed") {
+      const pending = state.pending.get(event.data.inboxID)
       if (!pending) return
-      state.pending.set(event.data.inputID, { ...pending, delivery: "steer" })
+      state.pending.set(event.data.inboxID, { ...pending, delivery: event.data.delivery })
       syncPending()
-      if (state.messageIDs.has(event.data.inputID)) return
-      state.messageIDs.add(event.data.inputID)
+      if (event.data.delivery === "queue") return
+      if (state.messageIDs.has(event.data.inboxID)) return
+      state.messageIDs.add(event.data.inboxID)
       write([
         {
           kind: "user",
           source: "system",
           text: pending.prompt.text,
           phase: "start",
-          messageID: event.data.inputID,
+          messageID: event.data.inboxID,
         },
       ])
       return
     }
-    if (event.type === "session.input.queued") {
-      const pending = state.pending.get(event.data.inputID)
-      if (!pending) return
-      state.pending.set(event.data.inputID, { ...pending, delivery: "queue" })
-      syncPending()
-      return
-    }
-    if (event.type === "session.input.cancelled") {
-      state.admitted.delete(event.data.inputID)
-      if (state.pending.delete(event.data.inputID)) syncPending()
+    if (event.type === "session.inbox.cancelled") {
+      state.admitted.delete(event.data.inboxID)
+      if (state.pending.delete(event.data.inboxID)) syncPending()
       return
     }
     if (event.type === "session.step.started") {
@@ -1514,7 +1508,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
     next: SessionTurnInput,
     messageID: string,
     client: OpenCodeClient,
-    send: () => Promise<SessionPendingInfo | void>,
+    send: () => Promise<SessionInboxInfo | void>,
     onAdmitted?: () => void,
   ) => {
     const active: Wait = {

+ 2 - 2
packages/tui/src/mini/types.ts

@@ -23,7 +23,7 @@ import type {
 } from "@opencode-ai/client/promise"
 import type { Config } from "../config"
 import type { CliRenderer } from "@opentui/core"
-import type { SessionPending } from "@opencode-ai/schema/session-pending"
+import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
 
 export type RunFilePart = {
   type: "file"
@@ -73,7 +73,7 @@ export type RunProvider = {
   models: Record<string, RunProviderModel>
 }
 
-export type RunDelivery = SessionPending.Delivery
+export type RunDelivery = SessionInbox.Delivery
 
 export type RunPrompt = {
   messageID?: string

+ 10 - 10
packages/tui/src/routes/session/index.tsx

@@ -105,7 +105,7 @@ import { useArgs } from "../../context/args"
 import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
 import { useSessionTabs } from "../../context/session-tabs"
 import { createSingleFlight } from "../../util/single-flight"
-import type { SessionPending } from "@opencode-ai/schema/session-pending"
+import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
 import { generateThinkingSyntax } from "./thinking-syntax"
 import { createDelayedPresence } from "../../util/delayed-presence"
 
@@ -133,8 +133,8 @@ const context = createContext<{
   diffWrapMode: () => "word" | "none"
   models: () => ModelInfo[]
   config: ReturnType<typeof useConfig>["data"]
-  mutatePending: (action: PendingAction, inputID: string) => Promise<boolean>
-  pendingDelivery: (inputID: string) => SessionPending.Delivery | undefined
+  mutatePending: (action: PendingAction, inboxID: string) => Promise<boolean>
+  pendingDelivery: (inboxID: string) => SessionInbox.Delivery | undefined
 }>()
 
 function use() {
@@ -207,7 +207,7 @@ export function Session() {
   )
   const pendingDeliveries = createMemo(() => new Map(pendingUsers().map((item) => [item.id, item.delivery])))
   const queuedPrompts = createMemo(() =>
-    pendingUsers().flatMap((item) => (item.delivery === "queue" ? [{ id: item.id, text: item.data.text }] : [])),
+    pendingUsers().flatMap((item) => (item.delivery === "queue" ? [{ id: item.id, text: item.payload.text }] : [])),
   )
   const [composer, setComposer] = createStore({
     open: false,
@@ -398,14 +398,14 @@ export function Session() {
   const dialog = useDialog()
   const renderer = useRenderer()
   const runPendingAction = createSingleFlight<string>()
-  const mutatePending = async (action: PendingAction, inputID: string) => {
-    const result = await runPendingAction(inputID, async () => {
+  const mutatePending = async (action: PendingAction, inboxID: string) => {
+    const result = await runPendingAction(inboxID, async () => {
       const request =
         action === "steer"
-          ? client.api.session.pending.steer({ sessionID: route.sessionID, inputID })
+          ? client.api.session.inbox.steer({ sessionID: route.sessionID, inboxID })
           : action === "queue"
-            ? client.api.session.pending.queue({ sessionID: route.sessionID, inputID })
-            : client.api.session.pending.cancel({ sessionID: route.sessionID, inputID })
+            ? client.api.session.inbox.queue({ sessionID: route.sessionID, inboxID })
+            : client.api.session.inbox.cancel({ sessionID: route.sessionID, inboxID })
       const error = await request.then(
         () => undefined,
         (error) => error,
@@ -1033,7 +1033,7 @@ export function Session() {
         models,
         config,
         mutatePending,
-        pendingDelivery: (inputID) => pendingDeliveries().get(inputID),
+        pendingDelivery: (inboxID) => pendingDeliveries().get(inboxID),
       }}
     >
       <box flexDirection="row" flexGrow={1} minHeight={0}>

+ 8 - 13
packages/tui/src/routes/session/rows.ts

@@ -1,4 +1,4 @@
-import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
+import type { SessionInboxEnqueued, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
 import { createEffect, on, onCleanup, type Accessor } from "solid-js"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { useConfig } from "../../config"
@@ -17,7 +17,7 @@ export type CacheUsage = {
 
 export type SessionRow =
   | { type: "message"; messageID: string }
-  | { type: "compaction-queued"; inputID: string }
+  | { type: "compaction-queued"; inboxID: string }
   | { type: "part"; ref: PartRef }
   | {
       type: "group"
@@ -64,7 +64,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
       0,
       ...pending
         .filter((item) => item.type === "compaction")
-        .map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })),
+        .map((item): SessionRow => ({ type: "compaction-queued", inboxID: item.id })),
     )
     return rows
   }
@@ -209,21 +209,16 @@ export function createSessionRows(sessionID: Accessor<string>) {
   const message = (event: { id: string; data: { sessionID: string } }) => {
     if (event.data.sessionID === sessionID()) appendMessage(event.id.replace(/^evt_/, "msg_"))
   }
-  const input = (event: {
-    data: {
-      sessionID: string
-      inputID: string
-      input: { type: "user" } | { type: "synthetic"; data: { description?: string } }
-    }
-  }) => {
+  const input = (event: SessionInboxEnqueued) => {
     if (
       event.data.sessionID === sessionID() &&
-      (event.data.input.type === "user" || event.data.input.data.description?.trim())
+      (event.data.item.type === "user" ||
+        (event.data.item.type === "synthetic" && event.data.item.payload.description?.trim()))
     )
-      appendMessage(event.data.inputID)
+      appendMessage(event.data.inboxID)
   }
   const subscriptions = [
-    data.on("session.input.admitted", input),
+    data.on("session.inbox.enqueued", input),
     data.on("session.compaction.started", (event) => {
       if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID ?? event.id.replace(/^evt_/, "msg_"))
     }),

+ 3 - 3
packages/tui/test/app-lifecycle.test.tsx

@@ -83,7 +83,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
       })
     if (url.pathname === "/api/session/dummy") return json({ data: session })
     if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
-    if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
+    if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
     if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
     if (url.pathname === "/api/session/dummy/prompt") {
       promptRequests++
@@ -167,7 +167,7 @@ test("session title generated while an untitled session is loading remains visib
       return json({ data: session })
     }
     if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
-    if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
+    if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
     if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
   }, events)
   const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
@@ -243,7 +243,7 @@ test("session startup prompt is submitted exactly once", async () => {
     if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
     if (url.pathname === "/api/session/dummy") return json({ data: session })
     if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
-    if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
+    if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
     if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
     if (url.pathname === "/api/agent")
       return json({

+ 44 - 33
packages/tui/test/cli/tui/data.test.tsx

@@ -902,12 +902,12 @@ test("completes exploration when a queued prompt is promoted", async () => {
     emitEvent(events, {
       id: "evt_prompt_admitted",
       created: 3,
-      type: "session.input.admitted",
+      type: "session.inbox.enqueued",
       durable: durable(sessionID, 2),
       data: {
         sessionID,
-        inputID: "message-user",
-        input: { type: "user", data: { text: "Continue" }, delivery: "steer" },
+        inboxID: "message-user",
+        item: { type: "user", payload: { text: "Continue" }, delivery: "steer" },
       },
     })
     await wait(() => rows.at(-1)?.type === "message")
@@ -916,9 +916,12 @@ test("completes exploration when a queued prompt is promoted", async () => {
     emitEvent(events, {
       id: "evt_prompt_promoted",
       created: 4,
-      type: "session.input.promoted",
+      type: "session.inbox.delivered",
       durable: durable(sessionID, 3),
-      data: { sessionID, inputID: "message-user" },
+      data: {
+        sessionID,
+        inboxID: "message-user",
+      },
     })
     await wait(() => rows.find((row) => row.type === "group")?.completed === true)
     expect(rows.at(-1)).toEqual({ type: "message", messageID: "message-user" })
@@ -961,12 +964,12 @@ test("updates and removes queued inputs from durable lifecycle events", async ()
     emitEvent(events, {
       id: "evt_queue_admitted",
       created: 1,
-      type: "session.input.admitted",
+      type: "session.inbox.enqueued",
       durable: durable(sessionID),
       data: {
         sessionID,
-        inputID: "message-queued",
-        input: { type: "user", data: { text: "Steer me" }, delivery: "queue" },
+        inboxID: "message-queued",
+        item: { type: "user", payload: { text: "Steer me" }, delivery: "queue" },
       },
     })
     await wait(() => data.session.pending.list(sessionID).length === 1)
@@ -975,9 +978,9 @@ test("updates and removes queued inputs from durable lifecycle events", async ()
     emitEvent(events, {
       id: "evt_queue_steered",
       created: 2,
-      type: "session.input.steered",
+      type: "session.inbox.delivery.changed",
       durable: durable(sessionID, 1),
-      data: { sessionID, inputID: "message-queued" },
+      data: { sessionID, inboxID: "message-queued", delivery: "steer" },
     })
     await wait(() =>
       data.session.pending
@@ -989,9 +992,9 @@ test("updates and removes queued inputs from durable lifecycle events", async ()
     emitEvent(events, {
       id: "evt_queue_restored",
       created: 3,
-      type: "session.input.queued",
+      type: "session.inbox.delivery.changed",
       durable: durable(sessionID, 2),
-      data: { sessionID, inputID: "message-queued" },
+      data: { sessionID, inboxID: "message-queued", delivery: "queue" },
     })
     await wait(() =>
       data.session.pending
@@ -1003,21 +1006,21 @@ test("updates and removes queued inputs from durable lifecycle events", async ()
     emitEvent(events, {
       id: "evt_cancel_admitted",
       created: 4,
-      type: "session.input.admitted",
+      type: "session.inbox.enqueued",
       durable: durable(sessionID, 3),
       data: {
         sessionID,
-        inputID: "message-cancelled",
-        input: { type: "user", data: { text: "Delete me" }, delivery: "queue" },
+        inboxID: "message-cancelled",
+        item: { type: "user", payload: { text: "Delete me" }, delivery: "queue" },
       },
     })
     await wait(() => data.session.pending.list(sessionID).length === 2)
     emitEvent(events, {
       id: "evt_queue_cancelled",
       created: 5,
-      type: "session.input.cancelled",
+      type: "session.inbox.cancelled",
       durable: durable(sessionID, 4),
-      data: { sessionID, inputID: "message-cancelled" },
+      data: { sessionID, inboxID: "message-cancelled" },
     })
     await wait(() => !data.session.input.has(sessionID, "message-cancelled"))
     expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-queued"])
@@ -1102,13 +1105,13 @@ test("removes committed revert messages from local state", async () => {
   ))
 
   try {
-    for (const [seq, inputID] of ["msg_001", "msg_002", "msg_003"].entries()) {
+    for (const [seq, inboxID] of ["msg_001", "msg_002", "msg_003"].entries()) {
       emitEvent(events, {
         id: Event.ID.create(),
         created: seq,
-        type: "session.input.admitted",
+        type: "session.inbox.enqueued",
         durable: durable(sessionID, seq),
-        data: { sessionID, inputID, input: { type: "user", data: { text: inputID }, delivery: "steer" } },
+        data: { sessionID, inboxID, item: { type: "user", payload: { text: inboxID }, delivery: "steer" } },
       })
     }
     await wait(() => data.session.message.list(sessionID).length === 3)
@@ -1473,9 +1476,13 @@ test("tracks session status from active sessions and execution events", async ()
     emitEvent(events, {
       id: "evt_manual_compaction_admitted",
       created: 0,
-      type: "session.compaction.admitted",
+      type: "session.inbox.enqueued",
       durable: durable("session-manual", 1),
-      data: { sessionID: "session-manual", inputID: "message-compaction" },
+      data: {
+        sessionID: "session-manual",
+        inboxID: "message-compaction",
+        item: { type: "compaction", payload: {}, delivery: "queue" },
+      },
     })
     await wait(() => data.session.pending.list("session-manual").some((item) => item.id === "message-compaction"))
     emitEvent(events, {
@@ -1574,16 +1581,20 @@ test("restores queued compaction from durable pending input", async () => {
       sessionID,
       timeCreated: 1,
       type: "compaction" as const,
+      payload: {},
+      delivery: "queue" as const,
     },
     {
       id: "message-compaction-later",
       sessionID,
       timeCreated: 2,
       type: "compaction" as const,
+      payload: {},
+      delivery: "queue" as const,
     },
   ]
   const calls = createFetch((url) => {
-    if (url.pathname !== `/api/session/${sessionID}/pending`) return
+    if (url.pathname !== `/api/session/${sessionID}/inbox`) return
     return json({ data: pending })
   }, events)
   let data!: ReturnType<typeof useData>
@@ -1618,8 +1629,8 @@ test("restores queued compaction from durable pending input", async () => {
     ])
     await wait(() => rows.filter((row) => row.type === "compaction-queued").length === 2)
     expect(rows.filter((row) => row.type === "compaction-queued")).toEqual([
-      { type: "compaction-queued", inputID: "message-compaction-queued" },
-      { type: "compaction-queued", inputID: "message-compaction-later" },
+      { type: "compaction-queued", inboxID: "message-compaction-queued" },
+      { type: "compaction-queued", inboxID: "message-compaction-later" },
     ])
 
     emitEvent(events, {
@@ -2793,12 +2804,12 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
     emitEvent(events, {
       id: "evt_admitted_1",
       created: 0,
-      type: "session.input.admitted",
+      type: "session.inbox.enqueued",
       durable: durable(sessionID),
       data: {
         sessionID,
-        inputID: messageID,
-        input: { type: "user", data: { text: "hello" }, delivery: "steer" },
+        inboxID: messageID,
+        item: { type: "user", payload: { text: "hello" }, delivery: "steer" },
       },
     })
     await wait(() => sync.session.message.list(sessionID)?.length === 1)
@@ -2811,7 +2822,7 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
         sessionID,
         timeCreated: 0,
         type: "user",
-        data: { text: "hello" },
+        payload: { text: "hello" },
         delivery: "steer",
       },
     ])
@@ -2823,16 +2834,16 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
     emitEvent(events, {
       id: "evt_prompted_1",
       created: 0,
-      type: "session.input.promoted",
+      type: "session.inbox.delivered",
       durable: durable(sessionID, 1),
       data: {
         sessionID,
-        inputID: messageID,
+        inboxID: messageID,
       },
     })
 
-    await wait(() => received.at(-1) === "session.input.promoted")
-    expect(received.slice(-2)).toEqual(["session.input.admitted", "session.input.promoted"])
+    await wait(() => received.at(-1) === "session.inbox.delivered")
+    expect(received.slice(-2)).toEqual(["session.inbox.enqueued", "session.inbox.delivered"])
     unsubscribe()
     const message = sync.session.message.list(sessionID)?.[0]
     expect(message?.type).toBe("user")

+ 9 - 9
packages/tui/test/context/session-tabs.test.tsx

@@ -199,15 +199,15 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session
 
 test("user prompt admissions pulse an already-busy background tab", async () => {
   const setup = await renderSessionTabs("background")
-  const admitted = (sessionID: string, inputID: string): OpenCodeEvent => ({
-    id: `evt_${inputID}`,
+  const admitted = (sessionID: string, inboxID: string): OpenCodeEvent => ({
+    id: `evt_${inboxID}`,
     created: Date.now(),
-    type: "session.input.admitted",
-    durable: { aggregateID: sessionID, seq: Number(inputID.replace(/\D/g, "")), version: 1 },
+    type: "session.inbox.enqueued",
+    durable: { aggregateID: sessionID, seq: Number(inboxID.replace(/\D/g, "")), version: 1 },
     data: {
       sessionID,
-      inputID,
-      input: { type: "user", data: { text: inputID }, delivery: "steer" },
+      inboxID,
+      item: { type: "user", payload: { text: inboxID }, delivery: "steer" },
     },
   })
 
@@ -219,12 +219,12 @@ test("user prompt admissions pulse an already-busy background tab", async () =>
     setup.emit({
       id: "evt_context",
       created: Date.now(),
-      type: "session.input.admitted",
+      type: "session.inbox.enqueued",
       durable: { aggregateID: "background", seq: 0, version: 1 },
       data: {
         sessionID: "background",
-        inputID: "msg_context",
-        input: { type: "synthetic", data: { text: "editor context" }, delivery: "steer" },
+        inboxID: "msg_context",
+        item: { type: "synthetic", payload: { text: "editor context" }, delivery: "steer" },
       },
     })
     await Bun.sleep(20)

+ 5 - 5
packages/tui/test/mini/footer.view.test.tsx

@@ -129,7 +129,7 @@ async function renderFooter(
     onStatus?: (status: string) => void
     onMiniSettingChange?: (change: MiniSettingChange) => void
     queuedPrompts?: FooterQueuedPrompt[]
-    onQueuedPromptAction?: (action: "steer" | "cancel", inputID: string) => Promise<void>
+    onQueuedPromptAction?: (action: "steer" | "cancel", inboxID: string) => Promise<void>
   } = {},
 ) {
   const [view, setView] = createSignal<FooterView>(input.view ?? { type: "prompt" })
@@ -974,8 +974,8 @@ test("direct footer steers the oldest queued prompt from an empty composer", asy
       { messageID: "m-1", prompt: { text: "first", parts: [] }, delivery: "queue" },
       { messageID: "m-2", prompt: { text: "second", parts: [] }, delivery: "queue" },
     ],
-    onQueuedPromptAction: async (action, inputID) => {
-      if (action === "steer") steered.push(inputID)
+    onQueuedPromptAction: async (action, inboxID) => {
+      if (action === "steer") steered.push(inboxID)
     },
   })
 
@@ -1002,8 +1002,8 @@ test("direct footer does not steer queued work on a double submit", async () =>
       await Bun.sleep(10)
       return true
     },
-    onQueuedPromptAction: async (action, inputID) => {
-      if (action === "steer") steered.push(inputID)
+    onQueuedPromptAction: async (action, inboxID) => {
+      if (action === "steer") steered.push(inboxID)
     },
   })
 

+ 52 - 52
packages/tui/test/mini/stream-v2.transport.test.ts

@@ -81,7 +81,7 @@ function promptAdmission(input: Parameters<OpenCodeClient["session"]["prompt"]>[
     id: input.id ?? "msg_prompt",
     sessionID,
     type: "user" as const,
-    data: {
+    payload: {
       text: input.text,
       files: input.files,
       agents: input.agents,
@@ -140,7 +140,7 @@ function sdk(input: {
   globals?: FormInfo[]
   globalLocation?: { directory: string; workspaceID?: string }
   permissions?: Record<string, PermissionRequest[]>
-  pending?: Record<string, Awaited<ReturnType<OpenCodeClient["session"]["pending"]["list"]>>>
+  pending?: Record<string, Awaited<ReturnType<OpenCodeClient["session"]["inbox"]["list"]>>>
   wait?: () => Promise<void>
 }) {
   const client = OpenCode.make({ baseUrl: "https://opencode.test" })
@@ -178,7 +178,7 @@ function sdk(input: {
     }),
   )
   spyOn(client.session, "active").mockImplementation(() => ok(input.active?.() ?? {}))
-  spyOn(client.session.pending, "list").mockImplementation((request) => ok(input.pending?.[request.sessionID] ?? []))
+  spyOn(client.session.inbox, "list").mockImplementation((request) => ok(input.pending?.[request.sessionID] ?? []))
   spyOn(client.session, "wait").mockImplementation(() => input.wait?.() ?? ok(undefined))
   spyOn(client.session, "message").mockImplementation((request) => {
     const message = input.messages?.[request.sessionID]?.find((item) => item.id === request.messageID)
@@ -606,11 +606,11 @@ describe("V2 mini transport", () => {
     events.push({
       id: "evt_prompted",
       created: 0,
-      type: "session.input.promoted",
+      type: "session.inbox.delivered",
       durable: durable("ses_1"),
       data: {
         sessionID: "ses_1",
-        inputID: "msg_prompt",
+        inboxID: "msg_prompt",
       },
     })
     events.push({
@@ -667,7 +667,7 @@ describe("V2 mini transport", () => {
             sessionID: "ses_1",
             timeCreated: 1,
             type: "user",
-            data: { text: "follow up" },
+            payload: { text: "follow up" },
             delivery: "queue",
           },
           {
@@ -675,7 +675,7 @@ describe("V2 mini transport", () => {
             sessionID: "ses_1",
             timeCreated: 2,
             type: "user",
-            data: { text: "remove me" },
+            payload: { text: "remove me" },
             delivery: "queue",
           },
         ],
@@ -700,9 +700,9 @@ describe("V2 mini transport", () => {
     events.push({
       id: "evt_steered",
       created: 3,
-      type: "session.input.steered",
+      type: "session.inbox.delivery.changed",
       durable: durable("ses_1", 2),
-      data: { sessionID: "ses_1", inputID: "msg_queued" },
+      data: { sessionID: "ses_1", inboxID: "msg_queued", delivery: "steer" },
     })
     while (!ui.commits.some((item) => item.messageID === "msg_queued")) await Bun.sleep(0)
 
@@ -713,9 +713,9 @@ describe("V2 mini transport", () => {
     events.push({
       id: "evt_queued",
       created: 4,
-      type: "session.input.queued",
+      type: "session.inbox.delivery.changed",
       durable: durable("ses_1", 3),
-      data: { sessionID: "ses_1", inputID: "msg_queued" },
+      data: { sessionID: "ses_1", inboxID: "msg_queued", delivery: "queue" },
     })
     while (pending()?.length !== 2) await Bun.sleep(0)
     expect(pending()).toEqual([
@@ -725,18 +725,18 @@ describe("V2 mini transport", () => {
     events.push({
       id: "evt_cancelled",
       created: 5,
-      type: "session.input.cancelled",
+      type: "session.inbox.cancelled",
       durable: durable("ses_1", 4),
-      data: { sessionID: "ses_1", inputID: "msg_cancelled" },
+      data: { sessionID: "ses_1", inboxID: "msg_cancelled" },
     })
     while (pending()?.length !== 1) await Bun.sleep(0)
     expect(pending()).toEqual([["msg_queued", "queue"]])
     events.push({
       id: "evt_promoted",
       created: 6,
-      type: "session.input.promoted",
+      type: "session.inbox.delivered",
       durable: durable("ses_1", 5),
-      data: { sessionID: "ses_1", inputID: "msg_queued" },
+      data: { sessionID: "ses_1", inboxID: "msg_queued" },
     })
     while (pending()?.length !== 0) await Bun.sleep(0)
     expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toHaveLength(1)
@@ -763,12 +763,12 @@ describe("V2 mini transport", () => {
     events.push({
       id: "evt_earlier_admission",
       created: 3,
-      type: "session.input.admitted",
+      type: "session.inbox.enqueued",
       durable: durable("ses_1", 1),
       data: {
         sessionID: "ses_1",
-        inputID: "msg_earlier",
-        input: { type: "user", data: { text: "earlier" }, delivery: "steer" },
+        inboxID: "msg_earlier",
+        item: { type: "user", payload: { text: "earlier" }, delivery: "steer" },
       },
     })
     await Bun.sleep(10)
@@ -851,9 +851,9 @@ describe("V2 mini transport", () => {
     events.push({
       id: "evt_prompt_promoted",
       created: 2,
-      type: "session.input.promoted",
+      type: "session.inbox.delivered",
       durable: durable("ses_1", 2),
-      data: { sessionID: "ses_1", inputID: "msg_prompt" },
+      data: { sessionID: "ses_1", inboxID: "msg_prompt" },
     })
     await transport.admitPromptTurn(
       {
@@ -869,9 +869,9 @@ describe("V2 mini transport", () => {
     events.push({
       id: "evt_queued_promoted",
       created: 3,
-      type: "session.input.promoted",
+      type: "session.inbox.delivered",
       durable: durable("ses_1", 3),
-      data: { sessionID: "ses_1", inputID: "msg_queued" },
+      data: { sessionID: "ses_1", inboxID: "msg_queued" },
     })
     events.push({
       id: "evt_failed",
@@ -922,11 +922,11 @@ describe("V2 mini transport", () => {
         events.push({
           id: "evt_prompted",
           created: 0,
-          type: "session.input.promoted",
+          type: "session.inbox.delivered",
           durable: durable("ses_1"),
           data: {
             sessionID: "ses_1",
-            inputID: "msg_prompt",
+            inboxID: "msg_prompt",
           },
         })
         events.push({
@@ -1011,11 +1011,11 @@ describe("V2 mini transport", () => {
         events.push({
           id: "evt_prompted",
           created: 0,
-          type: "session.input.promoted",
+          type: "session.inbox.delivered",
           durable: durable("ses_1"),
           data: {
             sessionID: "ses_1",
-            inputID: "msg_prompt",
+            inboxID: "msg_prompt",
           },
         })
         events.push({
@@ -1099,11 +1099,11 @@ describe("V2 mini transport", () => {
         events.push({
           id: "evt_prompted",
           created: 0,
-          type: "session.input.promoted",
+          type: "session.inbox.delivered",
           durable: durable("ses_1"),
           data: {
             sessionID: "ses_1",
-            inputID: "msg_prompt",
+            inboxID: "msg_prompt",
           },
         })
         events.push({
@@ -1253,9 +1253,9 @@ describe("V2 mini transport", () => {
     second.push({
       id: "evt_prompted",
       created: 2,
-      type: "session.input.promoted",
+      type: "session.inbox.delivered",
       durable: durable("ses_1", 2),
-      data: { sessionID: "ses_1", inputID: "msg_prompt" },
+      data: { sessionID: "ses_1", inboxID: "msg_prompt" },
     })
     first.close()
     while (!ui.events.some((event) => event.type === "stream.patch" && event.patch.status === "reconnecting"))
@@ -1471,9 +1471,9 @@ describe("V2 mini transport", () => {
         secondEvents.push({
           id: "evt_replacement_prompt",
           created: 3,
-          type: "session.input.promoted",
+          type: "session.inbox.delivered",
           durable: durable("ses_1", 1),
-          data: { sessionID: "ses_1", inputID: "msg_replacement" },
+          data: { sessionID: "ses_1", inboxID: "msg_replacement" },
         })
         secondEvents.push({
           id: "evt_replacement_settled",
@@ -2317,11 +2317,11 @@ describe("V2 mini transport", () => {
     events.push({
       id: "evt_prompted",
       created: 0,
-      type: "session.input.promoted",
+      type: "session.inbox.delivered",
       durable: durable("ses_1"),
       data: {
         sessionID: "ses_1",
-        inputID: "msg_prompt",
+        inboxID: "msg_prompt",
       },
     })
     events.push({
@@ -2378,11 +2378,11 @@ describe("V2 mini transport", () => {
     events.push({
       id: "evt_prompted",
       created: 0,
-      type: "session.input.promoted",
+      type: "session.inbox.delivered",
       durable: durable("ses_1"),
       data: {
         sessionID: "ses_1",
-        inputID: "msg_prompt",
+        inboxID: "msg_prompt",
       },
     })
     await Bun.sleep(0)
@@ -2799,11 +2799,11 @@ describe("V2 mini transport", () => {
         events.push({
           id: "evt_prompted",
           created: 0,
-          type: "session.input.promoted",
+          type: "session.inbox.delivered",
           durable: durable("ses_1"),
           data: {
             sessionID: "ses_1",
-            inputID: "msg_cmd",
+            inboxID: "msg_cmd",
           },
         })
         events.push({
@@ -2818,7 +2818,7 @@ describe("V2 mini transport", () => {
         id: input.id ?? "msg_cmd",
         sessionID: "ses_1",
         type: "user" as const,
-        data: { text: "evaluated template" },
+        payload: { text: "evaluated template" },
         delivery: "steer" as const,
         timeCreated: 2,
       })
@@ -2956,9 +2956,9 @@ describe("V2 mini transport", () => {
         events.push({
           id: "evt_prompted",
           created: 0,
-          type: "session.input.promoted",
+          type: "session.inbox.delivered",
           durable: durable("ses_1"),
-          data: { sessionID: "ses_1", inputID: "msg_skill_attachment" },
+          data: { sessionID: "ses_1", inboxID: "msg_skill_attachment" },
         })
         events.push({
           id: "evt_settled",
@@ -2972,7 +2972,7 @@ describe("V2 mini transport", () => {
         id: input.id ?? "msg_skill_attachment",
         sessionID: "ses_1",
         type: "user" as const,
-        data: { text: input.text },
+        payload: { text: input.text },
         delivery: "steer" as const,
         timeCreated: 2,
       })
@@ -3477,12 +3477,12 @@ describe("V2 mini transport", () => {
     events.push({
       id: "evt_child_admitted",
       created: 1,
-      type: "session.input.admitted",
+      type: "session.inbox.enqueued",
       durable: durable("ses_child"),
       data: {
         sessionID: "ses_child",
-        inputID: "msg_child_prompt",
-        input: { type: "user", data: { text: "actual child prompt" }, delivery: "steer" },
+        inboxID: "msg_child_prompt",
+        item: { type: "user", payload: { text: "actual child prompt" }, delivery: "steer" },
       },
     })
     await Bun.sleep(0)
@@ -3495,9 +3495,9 @@ describe("V2 mini transport", () => {
     events.push({
       id: "evt_child_promoted",
       created: 2,
-      type: "session.input.promoted",
+      type: "session.inbox.delivered",
       durable: durable("ses_child", 1),
-      data: { sessionID: "ses_child", inputID: "msg_child_prompt" },
+      data: { sessionID: "ses_child", inboxID: "msg_child_prompt" },
     })
     while (
       !states()
@@ -3541,12 +3541,12 @@ describe("V2 mini transport", () => {
     events.push({
       id: "evt_child_admitted_race",
       created: 1,
-      type: "session.input.admitted",
+      type: "session.inbox.enqueued",
       durable: durable("ses_child"),
       data: {
         sessionID: "ses_child",
-        inputID: "msg_child_race",
-        input: { type: "user", data: { text: "prompt admitted before hydration" }, delivery: "steer" },
+        inboxID: "msg_child_race",
+        item: { type: "user", payload: { text: "prompt admitted before hydration" }, delivery: "steer" },
       },
     })
     await Bun.sleep(0)
@@ -3555,9 +3555,9 @@ describe("V2 mini transport", () => {
     events.push({
       id: "evt_child_promoted_race",
       created: 2,
-      type: "session.input.promoted",
+      type: "session.inbox.delivered",
       durable: durable("ses_child", 1),
-      data: { sessionID: "ses_child", inputID: "msg_child_race" },
+      data: { sessionID: "ses_child", inboxID: "msg_child_race" },
     })
     await Bun.sleep(0)
     releaseHydration()

+ 1 - 1
packages/workerd-spike/test/spike.test.ts

@@ -178,7 +178,7 @@ it("runs a full prompt turn against a fake provider and reads the durable log",
   const items = await timed("session log read", () => readLog(sessionID))
   console.log(`[info] durable log events: ${JSON.stringify(items.map((item) => item.type))}`)
   const eventTypes = items.map((item) => item.type)
-  expect(eventTypes).toContain("session.input.admitted")
+  expect(eventTypes).toContain("session.inbox.enqueued")
   expect(eventTypes).toContain("session.text.ended")
   expect(eventTypes).toContain("session.execution.succeeded")
   expect(eventTypes).toContain("log.synced")

Файловите разлики са ограничени, защото са твърде много
+ 299 - 106
packages/www/openapi.json


Файловите разлики са ограничени, защото са твърде много
+ 299 - 106
packages/www/public/openapi.json


Някои файлове не бяха показани, защото твърде много файлове са промени