Răsfoiți Sursa

refactor: restructure conversation runtime around thread entities

Kit Langton 6 luni în urmă
părinte
comite
46cc9e7567
59 a modificat fișierele cu 2518 adăugiri și 2066 ștergeri
  1. 14 12
      bun.lock
  2. 1 0
      packages/discord/.env.example
  3. 23 5
      packages/discord/README.md
  4. 13 10
      packages/discord/package.json
  5. 9 0
      packages/discord/src/agent-prompt.md
  6. 79 0
      packages/discord/src/app/layers.ts
  7. 5 0
      packages/discord/src/config.ts
  8. 87 172
      packages/discord/src/control/cli.ts
  9. 107 151
      packages/discord/src/control/controller.ts
  10. 40 48
      packages/discord/src/control/local-adapter.test.ts
  11. 44 44
      packages/discord/src/control/local-adapter.ts
  12. 16 16
      packages/discord/src/control/state.test.ts
  13. 26 28
      packages/discord/src/control/state.ts
  14. 18 44
      packages/discord/src/conversation/README.md
  15. 242 149
      packages/discord/src/conversation/conversation.test.ts
  16. 270 0
      packages/discord/src/conversation/conversation.ts
  17. 25 0
      packages/discord/src/conversation/dedup.test.ts
  18. 35 0
      packages/discord/src/conversation/dedup.ts
  19. 4 4
      packages/discord/src/conversation/history.ts
  20. 1 1
      packages/discord/src/conversation/inbox.ts
  21. 2 1
      packages/discord/src/conversation/index.ts
  22. 4 4
      packages/discord/src/conversation/model/errors.ts
  23. 24 24
      packages/discord/src/conversation/model/schema.ts
  24. 74 0
      packages/discord/src/conversation/offsets.test.ts
  25. 57 0
      packages/discord/src/conversation/offsets.ts
  26. 5 5
      packages/discord/src/conversation/outbox.ts
  27. 0 260
      packages/discord/src/conversation/services/conversation.ts
  28. 0 93
      packages/discord/src/conversation/services/ledger.test.ts
  29. 0 85
      packages/discord/src/conversation/services/ledger.ts
  30. 61 0
      packages/discord/src/conversation/thread/cluster/chat-live.ts
  31. 97 0
      packages/discord/src/conversation/thread/cluster/contracts.ts
  32. 65 0
      packages/discord/src/conversation/thread/cluster/control-live.ts
  33. 150 0
      packages/discord/src/conversation/thread/cluster/entity.test.ts
  34. 315 0
      packages/discord/src/conversation/thread/cluster/entity.ts
  35. 14 0
      packages/discord/src/conversation/thread/cluster/index.ts
  36. 4 4
      packages/discord/src/conversation/threads.ts
  37. 11 12
      packages/discord/src/db/client.ts
  38. 0 2
      packages/discord/src/db/init.ts
  39. 0 31
      packages/discord/src/db/migrations/0002_conversation_offsets.ts
  40. 177 335
      packages/discord/src/discord/adapter.ts
  41. 70 0
      packages/discord/src/discord/catchup.test.ts
  42. 42 0
      packages/discord/src/discord/catchup.ts
  43. 40 0
      packages/discord/src/discord/conversation-channels.ts
  44. 23 0
      packages/discord/src/discord/conversation-commands.ts
  45. 33 0
      packages/discord/src/discord/conversation-delivery.ts
  46. 47 0
      packages/discord/src/discord/conversation-history.ts
  47. 17 0
      packages/discord/src/errors.ts
  48. 5 4
      packages/discord/src/http/health.ts
  49. 4 56
      packages/discord/src/index.ts
  50. 23 0
      packages/discord/src/lib/actors/stateful.ts
  51. 30 18
      packages/discord/src/sandbox/daytona/service.ts
  52. 44 0
      packages/discord/src/sandbox/daytona/snapshot.ts
  53. 15 7
      packages/discord/src/sandbox/opencode/client.ts
  54. 0 124
      packages/discord/src/sandbox/pool.test.ts
  55. 0 305
      packages/discord/src/sandbox/pool.ts
  56. 4 12
      packages/discord/src/sandbox/provisioner.ts
  57. 1 0
      packages/discord/src/session/store.test.ts
  58. 0 0
      packages/discord/src/session/store.ts
  59. 1 0
      packages/discord/src/test/effect.ts

+ 14 - 12
bun.lock

@@ -218,19 +218,21 @@
       "version": "0.1.0",
       "dependencies": {
         "@daytonaio/sdk": "latest",
-        "@effect/ai": "^0.33.2",
-        "@effect/ai-anthropic": "^0.23.0",
-        "@effect/experimental": "^0.58.0",
-        "@effect/platform": "latest",
+        "@effect/ai": "0.33.2",
+        "@effect/ai-anthropic": "0.23.0",
+        "@effect/cluster": "0.56.4",
+        "@effect/experimental": "0.58.0",
+        "@effect/platform": "0.94.5",
         "@effect/platform-bun": "0.87.1",
-        "@effect/sql-sqlite-bun": "^0.50.0",
+        "@effect/rpc": "0.73.1",
+        "@effect/sql-sqlite-bun": "0.50.2",
         "@opencode-ai/sdk": "latest",
         "discord.js": "^14",
-        "effect": "^3",
+        "effect": "3.19.17",
       },
       "devDependencies": {
         "@effect/language-service": "0.73.1",
-        "@effect/sql": "^0.49.0",
+        "@effect/sql": "0.49.0",
         "@types/bun": "latest",
         "@types/node": "^22",
         "typescript": "^5",
@@ -907,19 +909,19 @@
 
     "@effect/ai-anthropic": ["@effect/ai-anthropic@0.23.0", "", { "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4" }, "peerDependencies": { "@effect/ai": "^0.33.0", "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "effect": "^3.19.13" } }, "sha512-ftkyQpTY4OCPTe9hlB53WCXseWm11lOf+zXDNCJG2nAqW5mZ+4kd/PPiqV7jd2zDukAtLc0I+R3EGs1fOLCG5A=="],
 
-    "@effect/cluster": ["@effect/cluster@0.56.3", "", { "dependencies": { "kubernetes-types": "^1.30.0" }, "peerDependencies": { "@effect/platform": "^0.94.4", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "@effect/workflow": "^0.16.0", "effect": "^3.19.16" } }, "sha512-SimiwC4Gzy71wnWOkAMdwxdo9CQhePGXrEz5OC5LQdIvoSzTCu9z82Rj8X5Vo1m1wlz6oNc3WmQLnwbbt/Eukg=="],
+    "@effect/cluster": ["@effect/cluster@0.56.4", "", { "dependencies": { "kubernetes-types": "^1.30.0" }, "peerDependencies": { "@effect/platform": "^0.94.5", "@effect/rpc": "^0.73.1", "@effect/sql": "^0.49.0", "@effect/workflow": "^0.16.0", "effect": "^3.19.17" } }, "sha512-7Je5/JlbZOlsSxsbKjr97dJed2cNGWsb+TLNgMcr5mRDbcWlFOTUGvsrisEJV6waosYLIg+2omPdvnvRoYKdhA=="],
 
     "@effect/experimental": ["@effect/experimental@0.58.0", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/platform": "^0.94.0", "effect": "^3.19.13", "ioredis": "^5", "lmdb": "^3" }, "optionalPeers": ["ioredis", "lmdb"] }, "sha512-IEP9sapjF6rFy5TkoqDPc86st/fnqUfjT7Xa3pWJrFGr1hzaMXHo+mWsYOZS9LAOVKnpHuVziDK97EP5qsCHVA=="],
 
     "@effect/language-service": ["@effect/language-service@0.73.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-FbOKzXmP1QM6/YMvDFZGTZ0Gk0AjKqRSY48dpAN0zUdVzq5xV/Us/6vZ5FcdmG6GjgSWP2rpESy59OMvfPeylw=="],
 
-    "@effect/platform": ["@effect/platform@0.94.4", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.16" } }, "sha512-mK8pbskFAcBRA5Ooyt02kCBuWltZakyaDcM4ByTY0jXQMFC3NUveNK62JVH7XB+f3XZ8OoBBxUnlLben4plEKQ=="],
+    "@effect/platform": ["@effect/platform@0.94.5", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.17" } }, "sha512-z05APUiDDPbodhTkH/RJqOLoCU11bU2IZLfcwLFrld03+ob1VeqRnELQlmueLIYm6NZifHAtjl32V+GRt34y4A=="],
 
     "@effect/platform-bun": ["@effect/platform-bun@0.87.1", "", { "dependencies": { "@effect/platform-node-shared": "^0.57.1", "multipasta": "^0.2.7" }, "peerDependencies": { "@effect/cluster": "^0.56.1", "@effect/platform": "^0.94.2", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "effect": "^3.19.15" } }, "sha512-I88d0YqWbvLY2GGeIxK3r5k0l/MoUCCnxiHJG+X6gqaHu+pIs0djDtJ+ORhw/3qha9ojcVu6pyaBmnUjgzQHWQ=="],
 
     "@effect/platform-node-shared": ["@effect/platform-node-shared@0.57.1", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "multipasta": "^0.2.7", "ws": "^8.18.2" }, "peerDependencies": { "@effect/cluster": "^0.56.1", "@effect/platform": "^0.94.2", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "effect": "^3.19.15" } }, "sha512-oX/bApMdoKsyrDiNdJxo7U9Rz1RXsjRv+ecfAPp1qGlSdGIo32wVRvJ2XCHqYj0sqaYJS0pU0/GCulRfVGuJag=="],
 
-    "@effect/rpc": ["@effect/rpc@0.73.0", "", { "dependencies": { "msgpackr": "^1.11.4" }, "peerDependencies": { "@effect/platform": "^0.94.0", "effect": "^3.19.13" } }, "sha512-iMPf6tTriz8sK0l5x4koFId8Hz5nFptHYg8WqyjHGIIVLTpZxuiSqhmXZG7FnAs5N2n6uCEws4wWGcIgXNUrFg=="],
+    "@effect/rpc": ["@effect/rpc@0.73.1", "", { "dependencies": { "msgpackr": "^1.11.4" }, "peerDependencies": { "@effect/platform": "^0.94.5", "effect": "^3.19.17" } }, "sha512-1Pn6GJl+gCdVl0O38zK8wU/I5UsG6RArDKd8Ndx8BhntZAPIBbuUFgG7KlAKTJjumBTO/YOEgKYdwma0rJq/Tw=="],
 
     "@effect/sql": ["@effect/sql@0.49.0", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "effect": "^3.19.13" } }, "sha512-9UEKR+z+MrI/qMAmSvb/RiD9KlgIazjZUCDSpwNgm0lEK9/Q6ExEyfziiYFVCPiptp52cBw8uBHRic8hHnwqXA=="],
 
@@ -2615,7 +2617,7 @@
 
     "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
 
-    "effect": ["effect@3.19.16", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-7+XC3vGrbAhCHd8LTFHvnZjRpZKZ8YHRZqJTkpNoxcJ2mCyNs2SwI+6VkV/ij8Y3YW7wfBN4EbU06/F5+m/wkQ=="],
+    "effect": ["effect@3.19.17", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-MChgn9z0y3KJ2DJ+VsZt1bdVDJ0bDIZt9zm4s3iPpRLzgcEGDOIP8pwADjAWifaP0S672nlRE5rZPBbyDcjn1g=="],
 
     "electron-to-chromium": ["electron-to-chromium@1.5.282", "", {}, "sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ=="],
 
@@ -4639,7 +4641,7 @@
 
     "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
 
-    "@opencode/discord/@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.65", "", {}, "sha512-XlpgQJQ5WwO4tYgyyHoTT0NAB5/1StXonabVUAYVpW0JdtbwWFSdFEaLkWx6CU7MNW6ELP+SMC4n6wWO6zRW8Q=="],
+    "@opencode/discord/@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.1", "", {}, "sha512-K5e15mIXTyAykBw0GX+8O28IJHlPMw1jI/m3SDu+hgUHjmg2refqLPqyuqv8hE2nRcuGi8HajhpDJjkO7H2S0A=="],
 
     "@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="],
 

+ 1 - 0
packages/discord/.env.example

@@ -9,6 +9,7 @@ DISCORD_COMMAND_GUILD_ID=    # Optional guild ID for instant slash command updat
 
 # Daytona
 DAYTONA_API_KEY=
+DAYTONA_SNAPSHOT=           # Optional; prebuilt snapshot name for faster sandbox creation
 
 # OpenCode (injected into sandboxes)
 OPENCODE_ZEN_API_KEY=

+ 23 - 5
packages/discord/README.md

@@ -97,6 +97,7 @@ This image does not require Docker Compose or special network wiring; only outbo
 | ---------------- | ---------------------------- | -------------------------------------------------- |
 | `DATABASE_PATH`  | `discord.sqlite`             | Path to the local SQLite file                      |
 | `GITHUB_TOKEN`   | _(empty)_                    | Injected into sandboxes for authenticated `gh` CLI |
+| `DAYTONA_SNAPSHOT` | _(empty)_                  | Prebuilt Daytona snapshot name for faster startup  |
 | `OPENCODE_MODEL` | `opencode/claude-sonnet-4-5` | Model used inside OpenCode sessions                |
 
 #### Optional — Bot Behavior
@@ -127,10 +128,25 @@ This image does not require Docker Compose or special network wiring; only outbo
 | `bun run dev`       | Watch mode                  |
 | `bun run start`     | Production run              |
 | `bun run db:init`   | Initialize/migrate database |
+| `bun run snapshot:create` | Build/activate a Daytona snapshot |
 | `bun run typecheck` | TypeScript checks           |
 | `bun run build`     | Bundle for deployment       |
 | `bun run check`     | Typecheck + build           |
 
+### Faster Sandbox Startup (Snapshot)
+
+Build and activate a reusable Daytona snapshot once:
+
+```bash
+bun run snapshot:create opencode-discord-v1
+```
+
+Then set this in `.env`:
+
+```bash
+DAYTONA_SNAPSHOT=opencode-discord-v1
+```
+
 ### Discord Slash Commands
 
 - `/status` — show current sandbox session for the thread
@@ -148,11 +164,13 @@ These map to the existing `!status` / `!reset` behavior.
 ```
 Discord / CLI
   └─ Conversation service (Inbox → turn logic → Outbox)
-       ├─ ConversationLedger (dedup, at-least-once delivery, replay on restart)
-       └─ ThreadAgentPool.getOrCreate(threadId)
-            ├─ active? → health check → reuse
-            ├─ paused? → SandboxProvisioner.resume() → reattach session
-            └─ missing? → SandboxProvisioner.provision() → new sandbox + session
+       ├─ IngressDedup (message-id dedup in conversation path)
+       ├─ OffsetStore (durable Discord catch-up offsets)
+       └─ ThreadChatCluster.send(threadId)
+            └─ ThreadEntity (cluster actor per thread)
+                 ├─ active? → health check → reuse
+                 ├─ paused? → SandboxProvisioner.resume() → reattach session
+                 └─ missing? → SandboxProvisioner.provision() → new sandbox + session
 ```
 
 Sessions are persisted in a local SQLite file. Sandbox filesystem (including OpenCode session state) survives pause/resume cycles via Daytona stop/start.

+ 13 - 10
packages/discord/package.json

@@ -8,9 +8,10 @@
     "dev": "bun run --watch src/index.ts",
     "dev:setup": "bun run db:init",
     "start": "bun run src/index.ts",
-    "conversation:cli": "bun run src/conversation/control/cli.ts",
-    "conversation:controller": "bun run src/conversation/control/controller.ts",
-    "conversation:ctl": "bun run src/conversation/control/controller.ts",
+    "conversation:cli": "bun run src/control/cli.ts",
+    "conversation:controller": "bun run src/control/controller.ts",
+    "conversation:ctl": "bun run src/control/controller.ts",
+    "snapshot:create": "bun run src/sandbox/daytona/snapshot.ts",
     "build": "bun build src/index.ts --target=bun --outdir=dist",
     "typecheck": "tsc --noEmit",
     "check": "bun run typecheck && bun run build",
@@ -18,19 +19,21 @@
   },
   "dependencies": {
     "@daytonaio/sdk": "latest",
-    "@effect/ai": "^0.33.2",
-    "@effect/ai-anthropic": "^0.23.0",
-    "@effect/experimental": "^0.58.0",
-    "@effect/sql-sqlite-bun": "^0.50.0",
-    "@effect/platform": "latest",
+    "@effect/ai": "0.33.2",
+    "@effect/ai-anthropic": "0.23.0",
+    "@effect/cluster": "0.56.4",
+    "@effect/experimental": "0.58.0",
+    "@effect/platform": "0.94.5",
     "@effect/platform-bun": "0.87.1",
+    "@effect/rpc": "0.73.1",
+    "@effect/sql-sqlite-bun": "0.50.2",
     "@opencode-ai/sdk": "latest",
     "discord.js": "^14",
-    "effect": "^3"
+    "effect": "3.19.17"
   },
   "devDependencies": {
     "@effect/language-service": "0.73.1",
-    "@effect/sql": "^0.49.0",
+    "@effect/sql": "0.49.0",
     "@types/bun": "latest",
     "@types/node": "^22",
     "typescript": "^5"

+ 9 - 0
packages/discord/src/agent-prompt.md

@@ -26,6 +26,15 @@ your job is to help people solve real code and operations problems quickly.
 - use github cli (`gh`) for issues/prs when asked, or when explicitly instructed to file findings.
 - use web lookup when external, time-sensitive, or non-repo facts are needed.
 
+## github identity and capabilities
+
+- your github account name is `opendude`.
+- your github identity is the account authenticated in the sandbox via `GH_TOKEN` / `GITHUB_TOKEN`.
+- if the user asks who you are on github, check with `gh auth status` or `gh api user`.
+- you can create branches, push commits, open pull requests, open issues, and post issue/pr comments when repo permissions allow.
+- default to creating pull requests against `dev` unless the user specifies another base branch.
+- do not merge pull requests unless the user explicitly asks and permissions allow it.
+
 ## github issue workflow
 
 when creating an issue, include:

+ 79 - 0
packages/discord/src/app/layers.ts

@@ -0,0 +1,79 @@
+import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic"
+import { SingleRunner, TestRunner } from "@effect/cluster"
+import { FetchHttpClient } from "@effect/platform"
+import { BunContext } from "@effect/platform-bun"
+import { Effect, Layer, LogLevel, Logger } from "effect"
+import { Conversation } from "../conversation/conversation"
+import { IngressDedup } from "../conversation/dedup"
+import { History } from "../conversation/history"
+import { OffsetStore } from "../conversation/offsets"
+import { ThreadChatClusterLive, ThreadControlClusterLive, ThreadEntityLive } from "../conversation/thread/cluster"
+import { AppConfig } from "../config"
+import { SqliteDb } from "../db/client"
+import { DiscordConversationServicesLive } from "../discord/adapter"
+import { DiscordClient } from "../discord/client"
+import { TurnRouter } from "../discord/turn-routing"
+import { LoggerLive } from "../observability/logger"
+import { DaytonaService } from "../sandbox/daytona/service"
+import { OpenCodeClient } from "../sandbox/opencode/client"
+import { SandboxProvisioner } from "../sandbox/provisioner"
+import { SessionStore } from "../session/store"
+
+export const AnthropicLayer = Layer.unwrapEffect(
+  Effect.gen(function* () {
+    const config = yield* AppConfig
+    return AnthropicLanguageModel.layer({ model: config.turnRoutingModel }).pipe(
+      Layer.provide(AnthropicClient.layer({
+        apiKey: config.openCodeZenApiKey,
+        apiUrl: "https://opencode.ai/zen",
+      })),
+      Layer.provide(FetchHttpClient.layer),
+    )
+  }),
+)
+
+const Base = Layer.mergeAll(AppConfig.layer, FetchHttpClient.layer, BunContext.layer)
+
+const AppBase = Layer.mergeAll(Base, LoggerLive)
+const AppWithSqlite = Layer.provideMerge(SqliteDb.layer, AppBase)
+const AppWithAnthropic = Layer.provideMerge(AnthropicLayer, AppWithSqlite)
+const AppWithDaytona = Layer.provideMerge(DaytonaService.layer, AppWithAnthropic)
+const AppWithOpenCode = Layer.provideMerge(OpenCodeClient.layer, AppWithDaytona)
+const AppWithRouting = Layer.provideMerge(TurnRouter.layer, AppWithOpenCode)
+const AppWithSessions = Layer.provideMerge(SessionStore.layer, AppWithRouting)
+const AppWithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, AppWithSessions)
+const AppWithOffsets = Layer.provideMerge(OffsetStore.layer, AppWithProvisioner)
+const AppWithDedup = Layer.provideMerge(IngressDedup.layer, AppWithOffsets)
+const AppWithDiscordClient = Layer.provideMerge(DiscordClient.layer, AppWithDedup)
+const AppWithDiscordAdapters = Layer.provideMerge(DiscordConversationServicesLive, AppWithDiscordClient)
+const AppWithRunner = Layer.provideMerge(SingleRunner.layer({ runnerStorage: "memory" }), AppWithDiscordAdapters)
+const AppWithThreadEntity = Layer.provideMerge(ThreadEntityLive, AppWithRunner)
+const AppWithThreadChat = Layer.provideMerge(ThreadChatClusterLive, AppWithThreadEntity)
+export const AppConversationLayer = Layer.provideMerge(Conversation.layer, AppWithThreadChat)
+
+const ControlBase = Layer.mergeAll(Base, Logger.minimumLogLevel(LogLevel.None))
+const ControlWithSqlite = Layer.provideMerge(SqliteDb.layer, ControlBase)
+const ControlWithDaytona = Layer.provideMerge(DaytonaService.layer, ControlWithSqlite)
+const ControlWithOpenCode = Layer.provideMerge(OpenCodeClient.layer, ControlWithDaytona)
+const ControlWithSessions = Layer.provideMerge(SessionStore.layer, ControlWithOpenCode)
+const ControlWithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, ControlWithSessions)
+const ControlWithRunner = Layer.provideMerge(TestRunner.layer, ControlWithProvisioner)
+const ControlWithHistory = Layer.provideMerge(History.passthrough, ControlWithRunner)
+const ControlWithThreadEntity = Layer.provideMerge(ThreadEntityLive, ControlWithHistory)
+const ControlWithThreadChat = Layer.provideMerge(ThreadChatClusterLive, ControlWithThreadEntity)
+export const ControlThreadLayer = Layer.provideMerge(ThreadControlClusterLive, ControlWithThreadChat)
+
+const CliBase = Layer.mergeAll(Base, Logger.minimumLogLevel(LogLevel.Warning))
+const CliWithSqlite = Layer.provideMerge(SqliteDb.layer, CliBase)
+const CliWithAnthropic = Layer.provideMerge(AnthropicLayer, CliWithSqlite)
+const CliWithDaytona = Layer.provideMerge(DaytonaService.layer, CliWithAnthropic)
+const CliWithOpenCode = Layer.provideMerge(OpenCodeClient.layer, CliWithDaytona)
+const CliWithRouting = Layer.provideMerge(TurnRouter.layer, CliWithOpenCode)
+const CliWithSessions = Layer.provideMerge(SessionStore.layer, CliWithRouting)
+const CliWithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, CliWithSessions)
+const CliWithRunner = Layer.provideMerge(TestRunner.layer, CliWithProvisioner)
+const CliWithHistory = Layer.provideMerge(History.passthrough, CliWithRunner)
+const CliWithThreadEntity = Layer.provideMerge(ThreadEntityLive, CliWithHistory)
+const CliWithThreadChat = Layer.provideMerge(ThreadChatClusterLive, CliWithThreadEntity)
+const CliWithThreadControl = Layer.provideMerge(ThreadControlClusterLive, CliWithThreadChat)
+export const CliConversationStaticLayer = Layer.provideMerge(IngressDedup.noop, CliWithThreadControl)

+ 5 - 0
packages/discord/src/config.ts

@@ -58,6 +58,7 @@ export declare namespace AppConfig {
     readonly discordCommandGuildId: string
     readonly databasePath: string
     readonly daytonaApiKey: Redacted.Redacted
+    readonly daytonaSnapshot: string
     readonly openCodeZenApiKey: Redacted.Redacted
     readonly githubToken: string
     readonly logLevel: LogLevel
@@ -102,6 +103,9 @@ export class AppConfig extends Context.Tag("@discord/AppConfig")<AppConfig, AppC
         Config.withDefault("discord.sqlite"),
       )
       const daytonaApiKey = yield* Config.redacted("DAYTONA_API_KEY")
+      const daytonaSnapshot = yield* Config.string("DAYTONA_SNAPSHOT").pipe(
+        Config.withDefault(""),
+      )
       const openCodeZenApiKey = yield* Config.redacted("OPENCODE_ZEN_API_KEY")
       const githubToken = yield* Config.string("GITHUB_TOKEN").pipe(
         Config.withDefault(""),
@@ -166,6 +170,7 @@ export class AppConfig extends Context.Tag("@discord/AppConfig")<AppConfig, AppC
         discordCommandGuildId,
         databasePath,
         daytonaApiKey,
+        daytonaSnapshot,
         openCodeZenApiKey,
         githubToken,
         logLevel,

+ 87 - 172
packages/discord/src/conversation/control/cli.ts → packages/discord/src/control/cli.ts

@@ -1,52 +1,15 @@
 import { stdin, stdout } from "node:process"
 import readline from "node:readline/promises"
-import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic"
-import { FetchHttpClient } from "@effect/platform"
-import { BunContext, BunRuntime } from "@effect/platform-bun"
+import { BunRuntime } from "@effect/platform-bun"
 import { Effect, Layer, LogLevel, Logger, Option, Stream } from "effect"
-import { AppConfig } from "../../config"
-import { TurnRouter } from "../../discord/turn-routing"
-import { SqliteDb } from "../../db/client"
-import { DaytonaService } from "../../sandbox/daytona"
-import { OpenCodeClient } from "../../sandbox/opencode-client"
-import { ThreadAgentPool } from "../../sandbox/pool"
-import { SandboxProvisioner } from "../../sandbox/provisioner"
-import { SessionStore } from "../../sessions/store"
-import { PreviewAccess, ThreadId } from "../../types"
-import type { Action } from "../model/schema"
-import { makeTui } from "../implementations/local"
-import { Conversation } from "../services/conversation"
-import { ConversationLedger } from "../services/ledger"
+import { CliConversationStaticLayer } from "../app/layers"
+import { ThreadChatCluster, ThreadControlCluster } from "../conversation/thread/cluster"
+import { ThreadId } from "../types"
+import type { Action } from "../conversation/model/schema"
+import { makeTui } from "./local-adapter"
+import { Conversation } from "../conversation/conversation"
 import { autoThread, base, channelFrom, parse, prompt, scopeText, threadFrom } from "./state"
 
-const AnthropicLayer = Layer.unwrapEffect(
-  Effect.gen(function* () {
-    const config = yield* AppConfig
-    return AnthropicLanguageModel.layer({ model: config.turnRoutingModel }).pipe(
-      Layer.provide(AnthropicClient.layer({
-        apiKey: config.openCodeZenApiKey,
-        apiUrl: "https://opencode.ai/zen",
-      })),
-      Layer.provide(FetchHttpClient.layer),
-    )
-  }),
-)
-
-const BaseLayer = Layer.mergeAll(
-  AppConfig.layer,
-  FetchHttpClient.layer,
-  BunContext.layer,
-  Logger.minimumLogLevel(LogLevel.Warning),
-)
-const WithSqlite = Layer.provideMerge(SqliteDb.layer, BaseLayer)
-const WithAnthropic = Layer.provideMerge(AnthropicLayer, WithSqlite)
-const WithDaytona = Layer.provideMerge(DaytonaService.layer, WithAnthropic)
-const WithOpenCode = Layer.provideMerge(OpenCodeClient.layer, WithDaytona)
-const WithRouting = Layer.provideMerge(TurnRouter.layer, WithOpenCode)
-const WithSessions = Layer.provideMerge(SessionStore.layer, WithRouting)
-const WithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, WithSessions)
-const CoreLayer = Layer.provideMerge(ThreadAgentPool.layer, WithProvisioner)
-
 const colors = {
   reset: "\x1b[0m",
   dim: "\x1b[2m",
@@ -63,20 +26,14 @@ const run = Effect.gen(function* () {
   const tui = yield* makeTui
   const layer = Conversation.layer.pipe(
     Layer.provideMerge(tui.layer),
-    Layer.provideMerge(ConversationLedger.noop),
-    Layer.provideMerge(CoreLayer),
+    Layer.provideMerge(CliConversationStaticLayer),
   )
 
-    yield* Effect.gen(function* () {
-      const conversation = yield* Conversation
-      const config = yield* AppConfig
-      const pool = yield* ThreadAgentPool
-      const daytona = yield* DaytonaService
-      const oc = yield* OpenCodeClient
-    const sessions = yield* SessionStore
+  yield* Effect.gen(function* () {
+    const conversation = yield* Conversation
+    const threadChat = yield* ThreadChatCluster
+    const threadControl = yield* ThreadControlCluster
     const rl = readline.createInterface({ input: stdin, output: stdout, terminal: true })
-    const restart =
-      'pkill -f \'opencode serve --port 4096\' >/dev/null 2>&1 || true; for d in "$HOME/opencode" "/home/daytona/opencode" "/root/opencode"; do if [ -d "$d" ]; then cd "$d" && setsid opencode serve --port 4096 --hostname 0.0.0.0 > /tmp/opencode.log 2>&1 & exit 0; fi; done; exit 1'
     let scope = base()
     let pending = 0
     let last: ThreadId | null = null
@@ -106,55 +63,55 @@ const run = Effect.gen(function* () {
         stdout.write(`${prompt(scope)}${rl.line}`)
       })
 
-    const noteThread = (thread_id: ThreadId): Effect.Effect<void> =>
+    const noteThread = (threadId: ThreadId): Effect.Effect<void> =>
       Effect.sync(() => {
-        seen.add(thread_id)
-        last = thread_id
+        seen.add(threadId)
+        last = threadId
       })
 
-    const pick = (thread_id: ThreadId | null): ThreadId | null => {
-      if (thread_id) return thread_id
-      if (scope.kind === "thread") return scope.thread_id
+    const pick = (threadId: ThreadId | null): ThreadId | null => {
+      if (threadId) return threadId
+      if (scope.kind === "thread") return scope.threadId
       return last
     }
     const list = () => Array.from(seen)
     const byIndex = (index: number) => list().at(index - 1) ?? null
-    const fromRef = (thread_id: ThreadId | null) => {
-      if (!thread_id) return null
-      const raw = `${thread_id}`.trim()
-      if (!/^\d+$/.test(raw)) return thread_id
+    const fromRef = (threadId: ThreadId | null) => {
+      if (!threadId) return null
+      const raw = `${threadId}`.trim()
+      if (!/^\d+$/.test(raw)) return threadId
       const index = Number(raw)
       if (!Number.isInteger(index) || index <= 0) return null
       return byIndex(index)
     }
 
-    const tracked = (thread_id: ThreadId) =>
-      pool.getTrackedSession(thread_id).pipe(
+    const tracked = (threadId: ThreadId) =>
+      threadChat.status(threadId).pipe(
         Effect.map((row) => Option.isSome(row) ? row.value : null),
         Effect.catchAll(() => Effect.succeed(null)),
       )
 
-    const sessionText = (thread_id: ThreadId, session: {
+    const sessionText = (threadId: ThreadId, session: {
       status: string
       sandboxId: string
       sessionId: string
       resumeFailCount: number
       lastError: string | null
     }) =>
-      `${colors.dim}${thread_id}${colors.reset} status=${session.status} sandbox=${session.sandboxId} session=${session.sessionId} resume_failures=${session.resumeFailCount}${session.lastError ? ` error=${session.lastError.slice(0, 120)}` : ""}`
+      `${colors.dim}${threadId}${colors.reset} status=${session.status} sandbox=${session.sandboxId} session=${session.sessionId} resume_failures=${session.resumeFailCount}${session.lastError ? ` error=${session.lastError.slice(0, 120)}` : ""}`
 
     const render = (action: Action) => {
       if (action.kind === "typing") {
-        return stamp("typing", colors.yellow, `${colors.dim}[${action.thread_id}]${colors.reset}`)
+        return stamp("typing", colors.yellow, `${colors.dim}[${action.threadId}]${colors.reset}`)
       }
-      return stamp("assistant", colors.cyan, `${colors.dim}[${action.thread_id}]${colors.reset} ${action.text}`)
+      return stamp("assistant", colors.cyan, `${colors.dim}[${action.threadId}]${colors.reset} ${action.text}`)
     }
 
     yield* draw(
       stamp(
         "ready",
         colors.yellow,
-        `${colors.dim}Type messages. /thread [id|n], /pick [n], /channel, /threads, /status, /logs, /restart, /pause, /destroy, /resume, /active, /help, /exit${colors.reset}`,
+        `${colors.dim}Type messages. /thread [id|n], /pick [n], /channel, /threads, /status, /logs, /pause, /recreate, /resume, /active, /help, /exit${colors.reset}`,
       ),
       false,
     )
@@ -164,8 +121,8 @@ const run = Effect.gen(function* () {
         tui.actions,
         (action) =>
           Effect.gen(function* () {
-            const known = seen.has(action.thread_id)
-            yield* noteThread(action.thread_id)
+            const known = seen.has(action.threadId)
+            yield* noteThread(action.threadId)
             const next = autoThread(scope, action, known)
             const switched = scope.kind === "channel" && next.kind === "thread"
             scope = next
@@ -188,7 +145,7 @@ const run = Effect.gen(function* () {
         if (scope.kind === "channel") {
           yield* tui.send(text)
         } else {
-          yield* tui.sendTo(scope.thread_id, text)
+          yield* tui.sendTo(scope.threadId, text)
         }
         pending += 1
         yield* draw(stamp("queued", colors.green, `${colors.dim}[${target}]${colors.reset} ${text}`), false)
@@ -210,7 +167,7 @@ const run = Effect.gen(function* () {
 
         if (cmd.kind === "help") {
           yield* info(
-            `${colors.dim}/thread [id|n], /pick [n], /channel, /threads, /status [thread], /logs [lines] [thread], /restart [thread], /pause [thread], /destroy [thread], /resume [thread], /active, /exit${colors.reset}`,
+            `${colors.dim}/thread [id|n], /pick [n], /channel, /threads, /status [thread], /logs [lines] [thread], /pause [thread], /recreate [thread], /resume [thread], /active, /exit${colors.reset}`,
           )
           return true
         }
@@ -234,18 +191,18 @@ const run = Effect.gen(function* () {
             yield* info(`${colors.dim}pick one with /pick <n>${colors.reset}`)
             return true
           }
-          const thread_id = byIndex(cmd.index)
-          if (!thread_id) {
+          const threadId = byIndex(cmd.index)
+          if (!threadId) {
             yield* info(`${colors.dim}invalid thread index ${cmd.index}${colors.reset}`)
             return true
           }
-          scope = threadFrom(scope, thread_id)
+          scope = threadFrom(scope, threadId)
           yield* info(`${colors.dim}using ${scopeText(scope)}${colors.reset}`)
           return true
         }
 
         if (cmd.kind === "active") {
-          yield* sessions.listActive().pipe(
+          yield* threadControl.active.pipe(
             Effect.matchEffect({
               onFailure: (error) =>
                 info(`${colors.red}active query failed${colors.reset} ${String(error)}`),
@@ -265,14 +222,14 @@ const run = Effect.gen(function* () {
         }
 
         if (cmd.kind === "thread") {
-          const selected = fromRef(cmd.thread_id)
+          const selected = fromRef(cmd.threadId)
           if (selected) {
             scope = threadFrom(scope, selected)
             yield* noteThread(selected)
             yield* info(`${colors.dim}using ${scopeText(scope)}${colors.reset}`)
             return true
           }
-          if (cmd.thread_id) {
+          if (cmd.threadId) {
             yield* info(`${colors.dim}invalid thread id/index${colors.reset}`)
             return true
           }
@@ -286,149 +243,107 @@ const run = Effect.gen(function* () {
         }
 
         if (cmd.kind === "status") {
-          const thread_id = pick(cmd.thread_id)
-          if (!thread_id) {
+          const threadId = pick(cmd.threadId)
+          if (!threadId) {
             yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
             return true
           }
-          yield* noteThread(thread_id)
-          const session = yield* tracked(thread_id)
+          yield* noteThread(threadId)
+          const session = yield* tracked(threadId)
           if (!session) {
-            yield* info(`${colors.dim}no tracked session for ${thread_id}${colors.reset}`)
+            yield* info(`${colors.dim}no tracked session for ${threadId}${colors.reset}`)
             return true
           }
-          yield* info(sessionText(thread_id, session))
+          yield* info(sessionText(threadId, session))
           return true
         }
 
         if (cmd.kind === "logs") {
-          const thread_id = pick(cmd.thread_id)
-          if (!thread_id) {
+          const threadId = pick(cmd.threadId)
+          if (!threadId) {
             yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
             return true
           }
-          yield* noteThread(thread_id)
-          const session = yield* tracked(thread_id)
-          if (!session) {
-            yield* info(`${colors.dim}no tracked session for ${thread_id}${colors.reset}`)
+          yield* noteThread(threadId)
+          const row = yield* threadControl.logs({ threadId, lines: cmd.lines }).pipe(
+            Effect.catchAll(() => Effect.succeed(Option.none())),
+          )
+          if (Option.isNone(row)) {
+            yield* info(`${colors.dim}no tracked session for ${threadId}${colors.reset}`)
             return true
           }
-          yield* daytona.exec(
-            session.sandboxId,
-            "read-opencode-log",
-            `cat /tmp/opencode.log 2>/dev/null | tail -${cmd.lines}`,
-          ).pipe(
-            Effect.matchEffect({
-              onFailure: (error) =>
-                info(`${colors.red}log read failed${colors.reset} ${String(error)}`),
-              onSuccess: (result) =>
-                block(
-                  stamp("logs", colors.blue, `${colors.dim}[${thread_id}]${colors.reset}`),
-                  result.output.trim() || "(empty log)",
-                ),
-            }),
+          yield* block(
+            stamp("logs", colors.blue, `${colors.dim}[${threadId}]${colors.reset}`),
+            row.value.output.trim() || "(empty log)",
           )
           return true
         }
 
         if (cmd.kind === "pause") {
-          const thread_id = pick(cmd.thread_id)
-          if (!thread_id) {
+          const threadId = pick(cmd.threadId)
+          if (!threadId) {
             yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
             return true
           }
-          yield* noteThread(thread_id)
-          yield* pool.pauseSession(thread_id, "manual-cli").pipe(
-            Effect.matchEffect({
-              onFailure: (error) =>
-                info(`${colors.red}pause failed${colors.reset} ${String(error)}`),
-              onSuccess: () =>
-                info(`${colors.dim}paused ${thread_id}${colors.reset}`),
-            }),
+          yield* noteThread(threadId)
+          const row = yield* threadControl.pause({ threadId, reason: "manual-cli" }).pipe(
+            Effect.catchAll(() => Effect.succeed(Option.none())),
           )
+          if (Option.isNone(row)) {
+            yield* info(`${colors.dim}no tracked session for ${threadId}${colors.reset}`)
+            return true
+          }
+          yield* info(`${colors.dim}paused ${threadId}${colors.reset}`)
           return true
         }
 
-        if (cmd.kind === "destroy") {
-          const thread_id = pick(cmd.thread_id)
-          if (!thread_id) {
+        if (cmd.kind === "recreate") {
+          const threadId = pick(cmd.threadId)
+          if (!threadId) {
             yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
             return true
           }
-          yield* noteThread(thread_id)
-          yield* pool.destroySession(thread_id).pipe(
+          yield* noteThread(threadId)
+          yield* threadChat.recreate(threadId).pipe(
             Effect.matchEffect({
               onFailure: (error) =>
-                info(`${colors.red}destroy failed${colors.reset} ${String(error)}`),
+                info(`${colors.red}recreate failed${colors.reset} ${String(error)}`),
               onSuccess: () =>
-                info(`${colors.dim}destroyed ${thread_id}${colors.reset}`),
+                info(`${colors.dim}recreated ${threadId}${colors.reset}`),
             }),
           )
           return true
         }
 
         if (cmd.kind === "resume") {
-          const thread_id = pick(cmd.thread_id)
-          if (!thread_id) {
+          const threadId = pick(cmd.threadId)
+          if (!threadId) {
             yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
             return true
           }
-          yield* noteThread(thread_id)
-          const session = yield* tracked(thread_id)
-          if (!session) {
-            yield* info(`${colors.dim}no tracked session for ${thread_id}${colors.reset}`)
+          const session = yield* tracked(threadId)
+          if (session === null) {
+            yield* info(`${colors.dim}no tracked session for ${threadId}${colors.reset}`)
             return true
           }
-          yield* pool.getOrCreate(thread_id, session.channelId, session.guildId).pipe(
+          yield* noteThread(threadId)
+          yield* threadControl.resume({
+            threadId,
+            channelId: session.channelId,
+            guildId: session.guildId,
+          }).pipe(
             Effect.matchEffect({
               onFailure: (error) =>
                 info(`${colors.red}resume failed${colors.reset} ${String(error)}`),
-              onSuccess: (agent) =>
-                agent.current().pipe(
-                  Effect.flatMap((current) =>
-                    info(
-                      `${colors.dim}resumed ${thread_id} sandbox=${current.sandboxId} session=${current.sessionId}${colors.reset}`,
-                    ),
-                  ),
-                  Effect.catchAll((error) =>
-                    info(`${colors.red}resume failed${colors.reset} ${String(error)}`),
-                  ),
+              onSuccess: (current) =>
+                info(
+                  `${colors.dim}resumed ${threadId} sandbox=${current.sandboxId} session=${current.sessionId}${colors.reset}`,
                 ),
             }),
           )
           return true
         }
 
-        if (cmd.kind === "restart") {
-          const thread_id = pick(cmd.thread_id)
-          if (!thread_id) {
-            yield* info(`${colors.dim}no thread selected. use /thread <id>${colors.reset}`)
-            return true
-          }
-          yield* noteThread(thread_id)
-          const session = yield* tracked(thread_id)
-          if (!session) {
-            yield* info(`${colors.dim}no tracked session for ${thread_id}${colors.reset}`)
-            return true
-          }
-          const restarted = yield* daytona.exec(session.sandboxId, "restart-opencode-serve", restart).pipe(
-            Effect.as(true),
-            Effect.catchAll((error) =>
-              info(`${colors.red}restart failed${colors.reset} ${String(error)}`).pipe(Effect.as(false)),
-            ),
-          )
-          if (!restarted) return true
-          const healthy = yield* oc.waitForHealthy(PreviewAccess.from(session), config.activeHealthCheckTimeoutMs).pipe(
-            Effect.catchAll(() => Effect.succeed(false)),
-          )
-          if (!healthy) {
-            yield* info(`${colors.red}restart ran, but health check failed${colors.reset}`)
-            return true
-          }
-          yield* info(`${colors.dim}restart complete and healthy${colors.reset}`)
-          return true
-        }
-
         yield* info(`${colors.dim}unknown command: /${cmd.name}${colors.reset}`)
         return true
       })

+ 107 - 151
packages/discord/src/conversation/control/controller.ts → packages/discord/src/control/controller.ts

@@ -1,30 +1,8 @@
-import { FetchHttpClient } from "@effect/platform"
-import { BunContext, BunRuntime } from "@effect/platform-bun"
-import { Duration, Effect, Exit, Fiber, Layer, LogLevel, Logger, Option, Ref, Schema } from "effect"
-import { AppConfig } from "../../config"
-import { SqliteDb } from "../../db/client"
-import { DaytonaService } from "../../sandbox/daytona"
-import { OpenCodeClient } from "../../sandbox/opencode-client"
-import { ThreadAgentPool } from "../../sandbox/pool"
-import { SandboxProvisioner } from "../../sandbox/provisioner"
-import { SessionStore } from "../../sessions/store"
-import { ChannelId, GuildId, PreviewAccess, SandboxId, SessionInfo, ThreadId } from "../../types"
-
-const BaseLayer = Layer.mergeAll(
-  AppConfig.layer,
-  FetchHttpClient.layer,
-  BunContext.layer,
-  Logger.minimumLogLevel(LogLevel.None),
-)
-const WithSqlite = Layer.provideMerge(SqliteDb.layer, BaseLayer)
-const WithDaytona = Layer.provideMerge(DaytonaService.layer, WithSqlite)
-const WithOpenCode = Layer.provideMerge(OpenCodeClient.layer, WithDaytona)
-const WithSessions = Layer.provideMerge(SessionStore.layer, WithOpenCode)
-const WithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, WithSessions)
-const CoreLayer = Layer.provideMerge(ThreadAgentPool.layer, WithProvisioner)
-
-const restart =
-  'pkill -f \'opencode serve --port 4096\' >/dev/null 2>&1 || true; for d in "$HOME/opencode" "/home/daytona/opencode" "/root/opencode"; do if [ -d "$d" ]; then cd "$d" && setsid opencode serve --port 4096 --hostname 0.0.0.0 > /tmp/opencode.log 2>&1 & exit 0; fi; done; exit 1'
+import { BunRuntime } from "@effect/platform-bun"
+import { Duration, Effect, Exit, Fiber, Option, Ref, Schema } from "effect"
+import { ControlThreadLayer } from "../app/layers"
+import { ThreadChatCluster, ThreadControlCluster } from "../conversation/thread/cluster"
+import { ChannelId, GuildId, SandboxId, SessionInfo, ThreadId } from "../types"
 
 type Opt = Record<string, string | boolean>
 
@@ -111,6 +89,7 @@ const flag = (opts: Opt, key: string) => {
 }
 
 let ctlSeq = 0
+let msgSeq = 0
 const pick = (opts: Opt, active: ReadonlyArray<{ threadId: ThreadId }>) => {
   const raw = value(opts, "thread")
   if (raw) return Effect.succeed(ThreadId.make(raw))
@@ -119,6 +98,10 @@ const pick = (opts: Opt, active: ReadonlyArray<{ threadId: ThreadId }>) => {
   ctlSeq += 1
   return Effect.succeed(ThreadId.make(`ctl-${ctlSeq}`))
 }
+const messageId = () => {
+  msgSeq += 1
+  return `ctl-msg-${Date.now()}-${msgSeq}`
+}
 
 const print = (ok: boolean, command: string, payload: Record<string, unknown>) =>
   Effect.sync(() => {
@@ -145,49 +128,28 @@ const event = (command: string, name: string, payload: Record<string, unknown>)
 
 const run = Effect.gen(function* () {
   const ctl = parse(process.argv)
-  const config = yield* AppConfig
-  const pool = yield* ThreadAgentPool
-  const sessions = yield* SessionStore
-  const daytona = yield* DaytonaService
-  const oc = yield* OpenCodeClient
-  const active = yield* sessions.listActive()
-  const tracked = (thread_id: ThreadId) =>
-    Effect.gen(function* () {
-      const row = yield* pool.getTrackedSession(thread_id)
-      if (Option.isSome(row)) return row.value
-      return yield* usage(`no tracked session for thread ${thread_id}`)
-    })
-  const resolve = (thread_id: ThreadId, opts: Opt): Effect.Effect<SessionInfo, CtlUsageError | CtlInternalError> =>
+  const threadChat = yield* ThreadChatCluster
+  const threadControl = yield* ThreadControlCluster
+  const active = yield* threadControl.active
+  const status = (threadId: ThreadId) =>
+    threadChat.status(threadId).pipe(
+      Effect.catchAll((cause) => internal(cause)),
+    )
+  const context = (threadId: ThreadId, opts: Opt) =>
     Effect.gen(function* () {
-      const row = yield* pool.getTrackedSession(thread_id).pipe(
-        Effect.catchAll((cause) =>
-          internal(cause),
-        ),
-      )
+      const row = yield* status(threadId)
       if (Option.isSome(row)) {
-        const agent = yield* pool.getOrCreate(thread_id, row.value.channelId, row.value.guildId).pipe(
-          Effect.catchAll((cause) =>
-            internal(cause),
-          ),
-        )
-        return yield* agent.current().pipe(
-          Effect.catchAll((cause) =>
-            internal(cause),
-          ),
-        )
+        return {
+          channelId: row.value.channelId,
+          guildId: row.value.guildId,
+        }
       }
       const channel = value(opts, "channel") ?? "ctl"
       const guild = value(opts, "guild") ?? "local"
-      const agent = yield* pool.getOrCreate(thread_id, ChannelId.make(channel), GuildId.make(guild)).pipe(
-        Effect.catchAll((cause) =>
-          internal(cause),
-        ),
-      )
-      return yield* agent.current().pipe(
-        Effect.catchAll((cause) =>
-          internal(cause),
-        ),
-      )
+      return {
+        channelId: ChannelId.make(channel),
+        guildId: GuildId.make(guild),
+      }
     })
 
   if (ctl.cmd === "help") {
@@ -197,9 +159,8 @@ const run = Effect.gen(function* () {
         "conversation:ctl status --thread <id>",
         "conversation:ctl logs --thread <id> [--lines 120]",
         "conversation:ctl pause --thread <id>",
-        "conversation:ctl destroy --thread <id>",
+        "conversation:ctl recreate --thread <id>",
         "conversation:ctl resume --thread <id> [--channel <id> --guild <id>]",
-        "conversation:ctl restart --thread <id>",
         "conversation:ctl send --thread <id> --text <message> [--follow --wait-ms 180000 --logs-every-ms 2000 --lines 80]",
       ],
     })
@@ -222,62 +183,69 @@ const run = Effect.gen(function* () {
   }
 
   if (ctl.cmd === "status") {
-    const thread_id = yield* pick(ctl.opts, active)
-    return yield* pool.getTrackedSession(thread_id).pipe(
-      Effect.flatMap((row) =>
-        print(true, ctl.cmd, {
-          threadId: thread_id,
-          tracked: Option.isSome(row),
-          session: Option.isSome(row)
-            ? {
-                threadId: row.value.threadId,
-                channelId: row.value.channelId,
-                guildId: row.value.guildId,
-                sandboxId: row.value.sandboxId,
-                sessionId: row.value.sessionId,
-                status: row.value.status,
-                resumeFailCount: row.value.resumeFailCount,
-                lastError: row.value.lastError,
-              }
-            : null,
-        })),
-    )
+    const threadId = yield* pick(ctl.opts, active)
+    const row = yield* status(threadId)
+    return yield* print(true, ctl.cmd, {
+      threadId: threadId,
+      tracked: Option.isSome(row),
+      session: Option.isSome(row)
+        ? {
+            threadId: row.value.threadId,
+            channelId: row.value.channelId,
+            guildId: row.value.guildId,
+            sandboxId: row.value.sandboxId,
+            sessionId: row.value.sessionId,
+            status: row.value.status,
+            resumeFailCount: row.value.resumeFailCount,
+            lastError: row.value.lastError,
+          }
+        : null,
+    })
   }
 
   if (ctl.cmd === "logs") {
-    const thread_id = yield* pick(ctl.opts, active)
+    const threadId = yield* pick(ctl.opts, active)
     const lines = number(ctl.opts, "lines", 120)
-    const row = yield* tracked(thread_id)
-    const out = yield* daytona.exec(
-      row.sandboxId,
-      "read-opencode-log",
-      `cat /tmp/opencode.log 2>/dev/null | tail -${lines}`,
+    const row = yield* threadControl.logs({ threadId, lines }).pipe(
+      Effect.catchAll((cause) => internal(cause)),
     )
+    if (Option.isNone(row)) return yield* usage(`no tracked session for thread ${threadId}`)
     return yield* print(true, ctl.cmd, {
-      threadId: thread_id,
-      sandboxId: row.sandboxId,
+      threadId: threadId,
+      sandboxId: row.value.sandboxId,
       lines,
-      output: out.output,
+      output: row.value.output,
     })
   }
 
   if (ctl.cmd === "pause") {
-    const thread_id = yield* pick(ctl.opts, active)
-    yield* pool.pauseSession(thread_id, "manual-ctl")
-    return yield* print(true, ctl.cmd, { threadId: thread_id })
+    const threadId = yield* pick(ctl.opts, active)
+    const row = yield* threadControl.pause({ threadId, reason: "manual-ctl" }).pipe(
+      Effect.catchAll((cause) => internal(cause)),
+    )
+    if (Option.isNone(row)) return yield* usage(`no tracked session for thread ${threadId}`)
+    return yield* print(true, ctl.cmd, { threadId: threadId })
   }
 
-  if (ctl.cmd === "destroy") {
-    const thread_id = yield* pick(ctl.opts, active)
-    yield* pool.destroySession(thread_id)
-    return yield* print(true, ctl.cmd, { threadId: thread_id })
+  if (ctl.cmd === "recreate" || ctl.cmd === "destroy") {
+    const threadId = yield* pick(ctl.opts, active)
+    yield* threadChat.recreate(threadId)
+    return yield* print(true, ctl.cmd, { threadId: threadId })
   }
 
   if (ctl.cmd === "resume") {
-    const thread_id = yield* pick(ctl.opts, active)
-    const row = yield* resolve(thread_id, ctl.opts)
+    const threadId = yield* pick(ctl.opts, active)
+    const channel = value(ctl.opts, "channel")
+    const guild = value(ctl.opts, "guild")
+    const row = yield* threadControl.resume({
+      threadId,
+      channelId: channel ? ChannelId.make(channel) : null,
+      guildId: guild ? GuildId.make(guild) : null,
+    }).pipe(
+      Effect.catchAll((cause) => internal(cause)),
+    )
     return yield* print(true, ctl.cmd, {
-      threadId: thread_id,
+      threadId: threadId,
       session: {
         sandboxId: row.sandboxId,
         sessionId: row.sessionId,
@@ -286,22 +254,8 @@ const run = Effect.gen(function* () {
     })
   }
 
-  if (ctl.cmd === "restart") {
-    const thread_id = yield* pick(ctl.opts, active)
-    const row = yield* resolve(thread_id, ctl.opts)
-    yield* daytona.exec(row.sandboxId, "restart-opencode-serve", restart)
-    const healthy = yield* oc.waitForHealthy(PreviewAccess.from(row), config.activeHealthCheckTimeoutMs).pipe(
-      Effect.catchAll(() => Effect.succeed(false)),
-    )
-    return yield* print(true, ctl.cmd, {
-      threadId: thread_id,
-      sandboxId: row.sandboxId,
-      healthy,
-    })
-  }
-
   if (ctl.cmd === "send") {
-    const thread_id = yield* pick(ctl.opts, active)
+    const threadId = yield* pick(ctl.opts, active)
     const message = value(ctl.opts, "text") ?? ctl.args.join(" ").trim()
     if (!message) {
       return yield* usage("missing message text (pass --text \"...\")")
@@ -312,19 +266,23 @@ const run = Effect.gen(function* () {
     const follow = flag(ctl.opts, "follow") || wait > 0
 
     if (!follow) {
-      const row = yield* resolve(thread_id, ctl.opts)
-      const agent = yield* pool.getOrCreate(thread_id, row.channelId, row.guildId)
-      const reply = yield* agent.send(message)
-      const current = yield* agent.current()
+      const input = yield* context(threadId, ctl.opts)
+      const reply = yield* threadChat.send({
+        threadId,
+        channelId: input.channelId,
+        guildId: input.guildId,
+        messageId: messageId(),
+        text: message,
+      })
       return yield* print(true, ctl.cmd, {
-        threadId: thread_id,
-        sandboxId: current.sandboxId,
-        sessionId: current.sessionId,
-        reply,
+        threadId: threadId,
+        sandboxId: reply.session.sandboxId,
+        sessionId: reply.session.sessionId,
+        reply: reply.text,
       })
     }
 
-    const known = yield* pool.getTrackedSession(thread_id).pipe(
+    const known = yield* threadChat.status(threadId).pipe(
       Effect.catchAll(() => Effect.succeed(Option.none())),
     )
     const sandbox = yield* Ref.make<SandboxId | null>(Option.isSome(known) ? known.value.sandboxId : null)
@@ -333,17 +291,21 @@ const run = Effect.gen(function* () {
 
     const fiber = yield* Effect.fork(
       Effect.gen(function* () {
-        const row = yield* resolve(thread_id, ctl.opts)
-        yield* Ref.set(sandbox, row.sandboxId)
-        const agent = yield* pool.getOrCreate(thread_id, row.channelId, row.guildId)
-        const reply = yield* agent.send(message)
-        const current = yield* agent.current()
-        return { row: current, reply }
+        const input = yield* context(threadId, ctl.opts)
+        const reply = yield* threadChat.send({
+          threadId,
+          channelId: input.channelId,
+          guildId: input.guildId,
+          messageId: messageId(),
+          text: message,
+        })
+        yield* Ref.set(sandbox, reply.session.sandboxId)
+        return { row: reply.session, reply: reply.text }
       }),
     )
 
     yield* event(ctl.cmd, "started", {
-      threadId: thread_id,
+      threadId: threadId,
       waitMs: wait,
       logsEveryMs: every,
       lines,
@@ -367,7 +329,7 @@ const run = Effect.gen(function* () {
         const sandboxId = yield* Ref.get(sandbox)
         if (!sandboxId) {
           yield* event(ctl.cmd, "progress", {
-            threadId: thread_id,
+            threadId: threadId,
             elapsedMs: elapsed,
             stage: "resolving-session",
           })
@@ -375,29 +337,23 @@ const run = Effect.gen(function* () {
           return yield* loop()
         }
 
-        const output = yield* daytona.exec(
-          sandboxId,
-          "read-opencode-log",
-          `cat /tmp/opencode.log 2>/dev/null | tail -${lines}`,
-        ).pipe(
-          Effect.map((row) => row.output),
-          Effect.catchAll((cause) =>
-            Effect.succeed(`(log read failed: ${text(cause)})`),
-          ),
+        const output = yield* threadControl.logs({ threadId, lines }).pipe(
+          Effect.map((row) => Option.isSome(row) ? row.value.output : "(no tracked session)"),
+          Effect.catchAll((cause) => Effect.succeed(`(log read failed: ${text(cause)})`)),
         )
 
         const previous = yield* Ref.get(last)
         if (output !== previous) {
           yield* Ref.set(last, output)
           yield* event(ctl.cmd, "progress", {
-            threadId: thread_id,
+            threadId: threadId,
             elapsedMs: elapsed,
             sandboxId,
             logs: output,
           })
         } else {
           yield* event(ctl.cmd, "progress", {
-            threadId: thread_id,
+            threadId: threadId,
             elapsedMs: elapsed,
             sandboxId,
             logs: "(no change)",
@@ -410,7 +366,7 @@ const run = Effect.gen(function* () {
 
     const result = yield* loop()
     return yield* print(true, ctl.cmd, {
-      threadId: thread_id,
+      threadId: threadId,
       sandboxId: result.row.sandboxId,
       sessionId: result.row.sessionId,
       reply: result.reply,
@@ -430,7 +386,7 @@ const run = Effect.gen(function* () {
 )
 
 run.pipe(
-  Effect.provide(CoreLayer),
+  Effect.provide(ControlThreadLayer),
   Effect.scoped,
   BunRuntime.runMain,
 )

+ 40 - 48
packages/discord/src/conversation/implementations/local/index.test.ts → packages/discord/src/control/local-adapter.test.ts

@@ -1,12 +1,12 @@
 import { describe, expect } from "bun:test"
 import { Deferred, Effect, Layer, Option } from "effect"
-import { TurnRouter, TurnRoutingDecision } from "../../../discord/turn-routing"
-import { ThreadAgentPool, type ThreadAgent } from "../../../sandbox/pool"
-import { effectTest, testConfigLayer } from "../../../test/effect"
-import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from "../../../types"
-import { Conversation } from "../../services/conversation"
-import { ConversationLedger } from "../../services/ledger"
-import { makeTui } from "./index"
+import { ThreadChatCluster, ThreadChatError } from "../conversation/thread/cluster"
+import { TurnRouter, TurnRoutingDecision } from "../discord/turn-routing"
+import { effectTest, testConfigLayer } from "../test/effect"
+import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from "../types"
+import { Conversation } from "../conversation/conversation"
+import { IngressDedup } from "../conversation/dedup"
+import { makeTui } from "./local-adapter"
 
 const makeSession = (id: string) =>
   SessionInfo.make({
@@ -31,42 +31,34 @@ const routerLayer = Layer.succeed(
   }),
 )
 
-const makePoolLayer = (opts: {
-  getOrCreate?: ThreadAgentPool.Service["getOrCreate"]
+const makeThreadChatLayer = (opts: {
   send?: (prompt: string) => string
   seen?: Array<string>
   gate?: Deferred.Deferred<void>
 }) => {
   const session = makeSession("s1")
   const seen = opts.seen ?? []
-  const defaultGetOrCreate: ThreadAgentPool.Service["getOrCreate"] = () =>
-    Effect.gen(function* () {
-      if (opts.gate) yield* Deferred.await(opts.gate)
-      return {
-        threadId: session.threadId,
-        session,
-        current: () => Effect.succeed(session),
-        send: (prompt: string) =>
-          Effect.sync(() => {
-            seen.push(prompt)
-            return opts.send ? opts.send(prompt) : `local:${prompt}`
-          }),
-        pause: () => Effect.void,
-        destroy: () => Effect.void,
-      } satisfies ThreadAgent
-    })
-
-  return Layer.succeed(
-    ThreadAgentPool,
-    ThreadAgentPool.of({
-      getOrCreate: opts.getOrCreate ?? defaultGetOrCreate,
-      hasTrackedThread: () => Effect.succeed(true),
-      getTrackedSession: () => Effect.succeed(Option.none()),
-      getActiveSessionCount: () => Effect.succeed(0),
-      pauseSession: () => Effect.void,
-      destroySession: () => Effect.void,
-    }),
-  )
+  return Layer.succeed(ThreadChatCluster, ThreadChatCluster.of({
+    send: (input) =>
+      Effect.gen(function* () {
+        if (opts.gate) yield* Deferred.await(opts.gate)
+        seen.push(input.text)
+        return {
+          text: opts.send ? opts.send(input.text) : `local:${input.text}`,
+          session,
+          changedSession: false,
+        }
+      }).pipe(
+        Effect.mapError((cause) =>
+          ThreadChatError.make({
+            threadId: input.threadId,
+            cause,
+            retriable: false,
+          })),
+      ),
+    status: () => Effect.succeed(Option.some(session)),
+    recreate: () => Effect.void,
+  }))
 }
 
 describe("makeTui", () => {
@@ -74,13 +66,13 @@ describe("makeTui", () => {
     Effect.gen(function* () {
       const seen: Array<string> = []
       const tui = yield* makeTui
-      const poolLayer = makePoolLayer({ seen })
+      const threadChatLayer = makeThreadChatLayer({ seen })
 
       const live = Conversation.layer.pipe(
         Layer.provideMerge(tui.layer),
-        Layer.provideMerge(ConversationLedger.noop),
+        Layer.provideMerge(IngressDedup.noop),
         Layer.provideMerge(routerLayer),
-        Layer.provideMerge(poolLayer),
+        Layer.provideMerge(threadChatLayer),
         Layer.provideMerge(testConfigLayer),
       )
 
@@ -96,7 +88,7 @@ describe("makeTui", () => {
         expect(seen).toEqual(["hello local"])
         expect(first.kind).toBe("typing")
         expect(second.kind).toBe("send")
-        expect(/^thread-[a-z]+-[a-z]+-\d+$/.test(String(second.thread_id))).toBe(true)
+        expect(/^thread-[a-z]+-[a-z]+-\d+$/.test(String(second.threadId))).toBe(true)
         if (second.kind === "send") expect(second.text).toBe("local:hello local")
       }).pipe(Effect.provide(live))
     }),
@@ -106,13 +98,13 @@ describe("makeTui", () => {
     Effect.gen(function* () {
       const gate = yield* Deferred.make<void>()
       const tui = yield* makeTui
-      const poolLayer = makePoolLayer({ gate })
+      const threadChatLayer = makeThreadChatLayer({ gate })
 
       const live = Conversation.layer.pipe(
         Layer.provideMerge(tui.layer),
-        Layer.provideMerge(ConversationLedger.noop),
+        Layer.provideMerge(IngressDedup.noop),
         Layer.provideMerge(routerLayer),
-        Layer.provideMerge(poolLayer),
+        Layer.provideMerge(threadChatLayer),
         Layer.provideMerge(testConfigLayer),
       )
 
@@ -134,13 +126,13 @@ describe("makeTui", () => {
   effectTest("channel messages create distinct threads", () =>
     Effect.gen(function* () {
       const tui = yield* makeTui
-      const poolLayer = makePoolLayer({})
+      const threadChatLayer = makeThreadChatLayer({})
 
       const live = Conversation.layer.pipe(
         Layer.provideMerge(tui.layer),
-        Layer.provideMerge(ConversationLedger.noop),
+        Layer.provideMerge(IngressDedup.noop),
         Layer.provideMerge(routerLayer),
-        Layer.provideMerge(poolLayer),
+        Layer.provideMerge(threadChatLayer),
         Layer.provideMerge(testConfigLayer),
       )
 
@@ -161,7 +153,7 @@ describe("makeTui", () => {
         expect(secondTyping.kind).toBe("typing")
         expect(secondSend.kind).toBe("send")
         if (firstTyping.kind === "typing" && secondTyping.kind === "typing") {
-          expect(firstTyping.thread_id === secondTyping.thread_id).toBe(false)
+          expect(firstTyping.threadId === secondTyping.threadId).toBe(false)
         }
       }).pipe(Effect.provide(live))
     }),

+ 44 - 44
packages/discord/src/conversation/implementations/local/index.ts → packages/discord/src/control/local-adapter.ts

@@ -1,13 +1,13 @@
 import { Effect, Layer, Queue, Schedule, Stream } from "effect"
-import { TYPING_INTERVAL } from "../../../discord/constants"
-import { ChannelId, GuildId, ThreadId } from "../../../types"
-import { ChannelMessage, Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "../../model/schema"
-import { History, Inbox, Outbox, Threads } from "../../services"
+import { TYPING_INTERVAL } from "../discord/constants"
+import { ChannelId, GuildId, ThreadId } from "../types"
+import { ChannelMessage, Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "../conversation/model/schema"
+import { History, Inbox, Outbox, Threads } from "../conversation"
 
 export type Tui = {
   layer: Layer.Layer<Inbox | Outbox | History | Threads, never, never>
   send: (text: string) => Effect.Effect<void>
-  sendTo: (thread_id: ThreadId, text: string) => Effect.Effect<void>
+  sendTo: (threadId: ThreadId, text: string) => Effect.Effect<void>
   take: Effect.Effect<Action>
   actions: Stream.Stream<Action>
 }
@@ -33,32 +33,32 @@ export const makeTui = Effect.gen(function* () {
     return ThreadId.make(`thread-${x}-${y}-${z}`)
   }
 
-  const remember = (thread_id: ThreadId, line: string) => {
-    const current = history.get(thread_id)
+  const remember = (threadId: ThreadId, line: string) => {
+    const current = history.get(threadId)
     if (current) {
       current.push(line)
       return
     }
-    history.set(thread_id, [line])
+    history.set(threadId, [line])
   }
 
-  const sendTo = (thread_id: ThreadId, text: string) =>
+  const sendTo = (threadId: ThreadId, text: string) =>
     Effect.gen(function* () {
-      remember(thread_id, `user: ${text}`)
-      const channel_id = parents.get(thread_id) ?? ChannelId.make(`channel-${thread_id}`)
+      remember(threadId, `user: ${text}`)
+      const channelId = parents.get(threadId) ?? ChannelId.make(`channel-${threadId}`)
       yield* input.offer(
         ThreadMessage.make({
           kind: "thread_message",
-          thread_id,
-          channel_id,
-          message_id: crypto.randomUUID(),
-          guild_id: GuildId.make("local"),
-          bot_user_id: "local-bot",
-          bot_role_id: "",
-          author_id: "local-user",
-          author_is_bot: false,
-          mentions_everyone: false,
-          mentions: Mention.make({ user_ids: [], role_ids: [] }),
+          threadId,
+          channelId,
+          messageId: crypto.randomUUID(),
+          guildId: GuildId.make("local"),
+          botUserId: "local-bot",
+          botRoleId: "",
+          authorId: "local-user",
+          authorIsBot: false,
+          mentionsEveryone: false,
+          mentions: Mention.make({ userIds: [], roleIds: [] }),
           content: text,
         }),
       ).pipe(Effect.asVoid)
@@ -66,19 +66,19 @@ export const makeTui = Effect.gen(function* () {
 
   const send = (text: string) =>
     Effect.gen(function* () {
-      const channel_id = ChannelId.make("local-channel")
+      const channelId = ChannelId.make("local-channel")
       yield* input.offer(
         ChannelMessage.make({
           kind: "channel_message",
-          channel_id,
-          message_id: crypto.randomUUID(),
-          guild_id: GuildId.make("local"),
-          bot_user_id: "local-bot",
-          bot_role_id: "",
-          author_id: "local-user",
-          author_is_bot: false,
-          mentions_everyone: false,
-          mentions: Mention.make({ user_ids: ["local-bot"], role_ids: [] }),
+          channelId,
+          messageId: crypto.randomUUID(),
+          guildId: GuildId.make("local"),
+          botUserId: "local-bot",
+          botRoleId: "",
+          authorId: "local-user",
+          authorIsBot: false,
+          mentionsEveryone: false,
+          mentions: Mention.make({ userIds: ["local-bot"], roleIds: [] }),
           content: text,
         }),
       ).pipe(Effect.asVoid)
@@ -97,17 +97,17 @@ export const makeTui = Effect.gen(function* () {
         publish: (action) =>
           Effect.gen(function* () {
             if (action.kind === "send" || action.kind === "reply") {
-              remember(action.thread_id, `assistant: ${action.text}`)
+              remember(action.threadId, `assistant: ${action.text}`)
             }
             yield* output.offer(action).pipe(Effect.asVoid)
           }),
-        withTyping: <A, E, R>(thread_id: ThreadId, self: Effect.Effect<A, E, R>) =>
+        withTyping: <A, E, R>(threadId: ThreadId, self: Effect.Effect<A, E, R>) =>
           Effect.scoped(
             Effect.gen(function* () {
               const pulse = output.offer(
                 Typing.make({
                   kind: "typing",
-                  thread_id,
+                  threadId,
                 }),
               ).pipe(Effect.asVoid)
               yield* pulse
@@ -124,9 +124,9 @@ export const makeTui = Effect.gen(function* () {
     Layer.succeed(
       History,
       History.of({
-        rehydrate: (thread_id, latest: string) =>
+        rehydrate: (threadId, latest: string) =>
           Effect.sync(() => {
-            const lines = history.get(thread_id) ?? []
+            const lines = history.get(threadId) ?? []
             const prior = lines.at(-1) === `user: ${latest}` ? lines.slice(0, -1) : lines
             if (prior.length === 0) return latest
             return [
@@ -145,15 +145,15 @@ export const makeTui = Effect.gen(function* () {
         ensure: (event) =>
           Effect.sync(() => {
             if (event.kind === "thread_message") {
-              parents.set(event.thread_id, event.channel_id)
-              return ThreadRef.make({ thread_id: event.thread_id, channel_id: event.channel_id })
+              parents.set(event.threadId, event.channelId)
+              return ThreadRef.make({ threadId: event.threadId, channelId: event.channelId })
             }
-            const known = roots.get(event.message_id)
-            if (known) return ThreadRef.make({ thread_id: known, channel_id: event.channel_id })
-            const thread_id = name()
-            roots.set(event.message_id, thread_id)
-            parents.set(thread_id, event.channel_id)
-            return ThreadRef.make({ thread_id, channel_id: event.channel_id })
+            const known = roots.get(event.messageId)
+            if (known) return ThreadRef.make({ threadId: known, channelId: event.channelId })
+            const threadId = name()
+            roots.set(event.messageId, threadId)
+            parents.set(threadId, event.channelId)
+            return ThreadRef.make({ threadId, channelId: event.channelId })
           }),
       }),
     ),

+ 16 - 16
packages/discord/src/conversation/control/state.test.ts → packages/discord/src/control/state.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, it } from "bun:test"
-import { ThreadId } from "../../types"
-import { Send, Typing } from "../model/schema"
+import { ThreadId } from "../types"
+import { Send, Typing } from "../conversation/model/schema"
 import { autoThread, base, channelFrom, parse, prompt, queueTarget, scopeText, threadFrom } from "./state"
 
 describe("cli-state", () => {
@@ -12,18 +12,18 @@ describe("cli-state", () => {
     expect(parse("/pick")).toEqual({ kind: "pick", index: null })
     expect(parse("/pick 2")).toEqual({ kind: "pick", index: 2 })
     expect(parse("/active")).toEqual({ kind: "active" })
-    expect(parse("/thread")).toEqual({ kind: "thread", thread_id: null })
-    expect(parse("/thread abc")).toEqual({ kind: "thread", thread_id: ThreadId.make("abc") })
-    expect(parse("/status")).toEqual({ kind: "status", thread_id: null })
-    expect(parse("/status abc")).toEqual({ kind: "status", thread_id: ThreadId.make("abc") })
-    expect(parse("/logs")).toEqual({ kind: "logs", lines: 120, thread_id: null })
-    expect(parse("/logs 80")).toEqual({ kind: "logs", lines: 80, thread_id: null })
-    expect(parse("/logs abc")).toEqual({ kind: "logs", lines: 120, thread_id: ThreadId.make("abc") })
-    expect(parse("/logs 80 abc")).toEqual({ kind: "logs", lines: 80, thread_id: ThreadId.make("abc") })
-    expect(parse("/pause")).toEqual({ kind: "pause", thread_id: null })
-    expect(parse("/destroy")).toEqual({ kind: "destroy", thread_id: null })
-    expect(parse("/resume")).toEqual({ kind: "resume", thread_id: null })
-    expect(parse("/restart")).toEqual({ kind: "restart", thread_id: null })
+    expect(parse("/thread")).toEqual({ kind: "thread", threadId: null })
+    expect(parse("/thread abc")).toEqual({ kind: "thread", threadId: ThreadId.make("abc") })
+    expect(parse("/status")).toEqual({ kind: "status", threadId: null })
+    expect(parse("/status abc")).toEqual({ kind: "status", threadId: ThreadId.make("abc") })
+    expect(parse("/logs")).toEqual({ kind: "logs", lines: 120, threadId: null })
+    expect(parse("/logs 80")).toEqual({ kind: "logs", lines: 80, threadId: null })
+    expect(parse("/logs abc")).toEqual({ kind: "logs", lines: 120, threadId: ThreadId.make("abc") })
+    expect(parse("/logs 80 abc")).toEqual({ kind: "logs", lines: 80, threadId: ThreadId.make("abc") })
+    expect(parse("/pause")).toEqual({ kind: "pause", threadId: null })
+    expect(parse("/recreate")).toEqual({ kind: "recreate", threadId: null })
+    expect(parse("/destroy")).toEqual({ kind: "recreate", threadId: null })
+    expect(parse("/resume")).toEqual({ kind: "resume", threadId: null })
     expect(parse("/nope")).toEqual({ kind: "unknown", name: "nope" })
   })
 
@@ -41,8 +41,8 @@ describe("cli-state", () => {
 
   it("auto switches from channel to thread on action", () => {
     const a = base()
-    const typing = Typing.make({ kind: "typing", thread_id: ThreadId.make("t-a") })
-    const send = Send.make({ kind: "send", thread_id: ThreadId.make("t-b"), text: "ok" })
+    const typing = Typing.make({ kind: "typing", threadId: ThreadId.make("t-a") })
+    const send = Send.make({ kind: "send", threadId: ThreadId.make("t-b"), text: "ok" })
 
     expect(autoThread(a, typing)).toEqual(threadFrom(a, ThreadId.make("t-a")))
     expect(autoThread(a, send)).toEqual(threadFrom(a, ThreadId.make("t-b")))

+ 26 - 28
packages/discord/src/conversation/control/state.ts → packages/discord/src/control/state.ts

@@ -1,11 +1,11 @@
-import { ThreadId } from "../../types"
-import type { Action } from "../model/schema"
+import { ThreadId } from "../types"
+import type { Action } from "../conversation/model/schema"
 
 const LOCAL_CHANNEL = "local-channel" as const
 
 export type Scope =
-  | { kind: "channel"; channel_id: typeof LOCAL_CHANNEL }
-  | { kind: "thread"; thread_id: ThreadId; channel_id: typeof LOCAL_CHANNEL }
+  | { kind: "channel"; channelId: typeof LOCAL_CHANNEL }
+  | { kind: "thread"; threadId: ThreadId; channelId: typeof LOCAL_CHANNEL }
 
 export type Command =
   | { kind: "channel" }
@@ -13,16 +13,15 @@ export type Command =
   | { kind: "threads" }
   | { kind: "pick"; index: number | null }
   | { kind: "active" }
-  | { kind: "thread"; thread_id: ThreadId | null }
-  | { kind: "status"; thread_id: ThreadId | null }
-  | { kind: "logs"; thread_id: ThreadId | null; lines: number }
-  | { kind: "pause"; thread_id: ThreadId | null }
-  | { kind: "destroy"; thread_id: ThreadId | null }
-  | { kind: "resume"; thread_id: ThreadId | null }
-  | { kind: "restart"; thread_id: ThreadId | null }
+  | { kind: "thread"; threadId: ThreadId | null }
+  | { kind: "status"; threadId: ThreadId | null }
+  | { kind: "logs"; threadId: ThreadId | null; lines: number }
+  | { kind: "pause"; threadId: ThreadId | null }
+  | { kind: "recreate"; threadId: ThreadId | null }
+  | { kind: "resume"; threadId: ThreadId | null }
   | { kind: "unknown"; name: string }
 
-export const base = (): Scope => ({ kind: "channel", channel_id: LOCAL_CHANNEL })
+export const base = (): Scope => ({ kind: "channel", channelId: LOCAL_CHANNEL })
 
 const target = (value: string | undefined) => {
   const raw = value?.trim() ?? ""
@@ -56,49 +55,48 @@ export const parse = (line: string): Command | null => {
   if (head === "threads") return { kind: "threads" }
   if (head === "pick") return { kind: "pick", index: parseIndex(args.at(0)) }
   if (head === "active") return { kind: "active" }
-  if (head === "thread") return { kind: "thread", thread_id: target(args.at(0)) }
-  if (head === "status") return { kind: "status", thread_id: target(args.at(0)) }
+  if (head === "thread") return { kind: "thread", threadId: target(args.at(0)) }
+  if (head === "status") return { kind: "status", threadId: target(args.at(0)) }
 
   if (head === "logs") {
     const lines = parseLines(args.at(0))
     if (lines === null) {
-      return { kind: "logs", lines: 120, thread_id: target(args.at(0)) }
+      return { kind: "logs", lines: 120, threadId: target(args.at(0)) }
     }
-    return { kind: "logs", lines, thread_id: target(args.at(1)) }
+    return { kind: "logs", lines, threadId: target(args.at(1)) }
   }
 
-  if (head === "pause") return { kind: "pause", thread_id: target(args.at(0)) }
-  if (head === "destroy") return { kind: "destroy", thread_id: target(args.at(0)) }
-  if (head === "resume") return { kind: "resume", thread_id: target(args.at(0)) }
-  if (head === "restart") return { kind: "restart", thread_id: target(args.at(0)) }
+  if (head === "pause") return { kind: "pause", threadId: target(args.at(0)) }
+  if (head === "recreate" || head === "destroy") return { kind: "recreate", threadId: target(args.at(0)) }
+  if (head === "resume") return { kind: "resume", threadId: target(args.at(0)) }
 
   return { kind: "unknown", name: head }
 }
 
 export const scopeText = (scope: Scope) => scope.kind === "channel"
-  ? `channel:${scope.channel_id}`
-  : `thread:${scope.thread_id}`
+  ? `channel:${scope.channelId}`
+  : `thread:${scope.threadId}`
 
 export const prompt = (scope: Scope) => scope.kind === "channel"
   ? "channel> "
-  : `thread:${scope.thread_id}> `
+  : `thread:${scope.threadId}> `
 
 export const queueTarget = (scope: Scope) => scope.kind === "channel" ? "channel" : "thread"
 
-export const threadFrom = (scope: Scope, thread_id: ThreadId): Scope => ({
+export const threadFrom = (scope: Scope, threadId: ThreadId): Scope => ({
   kind: "thread",
-  channel_id: scope.channel_id,
-  thread_id,
+  channelId: scope.channelId,
+  threadId,
 })
 
 export const channelFrom = (scope: Scope): Scope => ({
   kind: "channel",
-  channel_id: scope.channel_id,
+  channelId: scope.channelId,
 })
 
 export const autoThread = (scope: Scope, action: Action, known = false): Scope => {
   if (scope.kind === "thread") return scope
   if (action.kind !== "typing" && action.kind !== "send" && action.kind !== "reply") return scope
   if (known) return scope
-  return threadFrom(scope, action.thread_id)
+  return threadFrom(scope, action.threadId)
 }

+ 18 - 44
packages/discord/src/conversation/README.md

@@ -1,51 +1,25 @@
-# Conversation Experiment
+# Conversation Runtime
 
-This folder contains the active Discord conversation runtime.
+This folder contains the active conversation runtime used by the Discord bot.
 
-Goal:
+## Active layout
 
-- keep inbound transport as a stream (`Inbox.events`)
-- keep outbound transport as actions (`Outbox.publish`)
-- support first-contact channel messages by resolving a thread target through `Threads.ensure`
-- move orchestration into a transport-agnostic `Conversation` service
+- `model/*`: normalized inbound events + outbound actions and conversation error model.
+- `conversation.ts`: core orchestration loop.
+- `inbox.ts`, `outbox.ts`, `threads.ts`, `history.ts`, `dedup.ts`, `offsets.ts`: service contracts used by the runtime.
+- `thread/cluster/*`: thread-scoped cluster runtime (`ThreadEntity`, `ThreadChatCluster`, `ThreadControlCluster`) for send/status/recreate/control.
+- `../discord/adapter.ts`: Discord adapter implementation for inbox/outbox/thread/history ports.
+- `../control/*`: local CLI/controller tooling.
 
-Current status:
+## Current execution path
 
-- `model/schema.ts`: normalized event/action schema (`thread_message` and `channel_message`)
-- `services/*`: service contracts (`Inbox`, `Outbox`, `History`, `Threads`, `ConversationLedger`) + `Conversation`
-- `implementations/local/index.ts`: local implementation with `send()` / `take()` for non-Discord chat loops
-- `implementations/discord/index.ts`: Discord implementation mapping message events to `Inbound` and actions to Discord sends
-- `control/state.ts` + `control/cli.ts` + `control/controller.ts`: local CLI state, interactive CLI, and non-interactive controller commands
+1. `Conversation.run` consumes `Inbox.events`.
+2. `Conversation.turn` routes and resolves a thread target.
+3. `Conversation` calls `ThreadChatCluster.send`.
+4. `ThreadEntity` handles per-thread lifecycle/recovery/idempotent send.
+5. `Outbox` publishes `typing` / `send` actions.
 
-This module is wired into `src/index.ts`.
+## Notes
 
-Reliability semantics:
-
-- in-memory dedup by `message_id` prevents double-processing within a session
-- startup catch-up fetches missed Discord messages from tracked thread sources and allowed channels using persisted offsets
-- Discord is the durable inbox — on startup we resume from where we left off per source
-
-Local CLI notes (`bun run conversation:cli`):
-
-- `typing` now emits as soon as a target thread is resolved (before sandbox/session resolution), so startup latency is visible.
-- channel and thread modes are explicit:
-  - `/channel` routes to top-level channel mode
-  - `/thread [id|n]` routes directly to thread mode (`n` is 1-based index from `/threads`; without arg, uses last seen thread)
-  - `/threads` lists known thread ids with indexes
-  - `/pick [n]` shows/selects a thread by index
-- auto-switch from channel mode now only follows newly-seen threads (prevents jumping to old threads still emitting output)
-- local thread simulation now mirrors Discord intent: each channel-mode message creates a new thread root, while explicit thread mode continues an existing thread
-- local thread ids are human-readable (`thread-adjective-noun-n`) to make `/threads` easy to scan
-
-Agent CLI notes (`bun run conversation:controller` or `bun run conversation:ctl`):
-
-- non-interactive JSON output for automation (`ok: true/false`)
-- commands:
-  - `active`
-  - `status --thread <id>`
-  - `logs --thread <id> [--lines 120]`
-  - `pause --thread <id>`
-  - `destroy --thread <id>`
-  - `resume --thread <id> [--channel <id> --guild <id>]`
-  - `restart --thread <id>`
-  - `send --thread <id> --text "<message>" [--follow --wait-ms 180000 --logs-every-ms 2000 --lines 80]`
+- This module is wired from `packages/discord/src/index.ts`.
+- Per-thread send idempotency is keyed by `messageId` in `thread/cluster` `send` RPC.

+ 242 - 149
packages/discord/src/conversation/services/conversation.test.ts → packages/discord/src/conversation/conversation.test.ts

@@ -1,14 +1,13 @@
 import { describe, expect } from "bun:test"
-import { Deferred, Effect, Fiber, Layer, Option, Stream } from "effect"
-import { TurnRouter, TurnRoutingDecision } from "../../discord/turn-routing"
-import { DatabaseError, OpenCodeClientError, SandboxDeadError } from "../../errors"
-import { ThreadAgentPool, type ThreadAgent } from "../../sandbox/pool"
-import { effectTest, testConfigLayer } from "../../test/effect"
-import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from "../../types"
-import { Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "../model/schema"
-import { History } from "./history"
+import { Deferred, Effect, Fiber, Layer, Option, Predicate, Stream } from "effect"
+import { ThreadChatCluster, ThreadChatError } from "./thread/cluster"
+import { TurnRouter, TurnRoutingDecision } from "../discord/turn-routing"
+import { classifyOpenCodeFailure, DatabaseError, OpenCodeClientError, SandboxDeadError } from "../errors"
+import { effectTest, testConfigLayer } from "../test/effect"
+import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from "../types"
+import { Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "./model/schema"
 import { Inbox } from "./inbox"
-import { ConversationLedger } from "./ledger"
+import { IngressDedup } from "./dedup"
 import { Outbox } from "./outbox"
 import { Threads } from "./threads"
 import { Conversation } from "./conversation"
@@ -35,16 +34,16 @@ const makeThreadEvent = (props: {
 }) =>
   ThreadMessage.make({
     kind: "thread_message",
-    thread_id: ThreadId.make(props.threadId),
-    channel_id: ChannelId.make(props.channelId),
-    message_id: props.messageId,
-    guild_id: GuildId.make("g1"),
-    bot_user_id: "bot-1",
-    bot_role_id: "role-1",
-    author_id: "u1",
-    author_is_bot: false,
-    mentions_everyone: false,
-    mentions: Mention.make({ user_ids: [], role_ids: [] }),
+    threadId: ThreadId.make(props.threadId),
+    channelId: ChannelId.make(props.channelId),
+    messageId: props.messageId,
+    guildId: GuildId.make("g1"),
+    botUserId: "bot-1",
+    botRoleId: "role-1",
+    authorId: "u1",
+    authorIsBot: false,
+    mentionsEveryone: false,
+    mentions: Mention.make({ userIds: [], roleIds: [] }),
     content: props.content,
   })
 
@@ -53,15 +52,15 @@ const makeEvent = (content: string) =>
 
 const makeChannelEvent = (content: string) => ({
   kind: "channel_message" as const,
-  channel_id: ChannelId.make("c-root"),
-  message_id: "m-root",
-  guild_id: GuildId.make("g1"),
-  bot_user_id: "bot-1",
-  bot_role_id: "role-1",
-  author_id: "u1",
-  author_is_bot: false,
-  mentions_everyone: false,
-  mentions: Mention.make({ user_ids: ["bot-1"], role_ids: [] }),
+  channelId: ChannelId.make("c-root"),
+  messageId: "m-root",
+  guildId: GuildId.make("g1"),
+  botUserId: "bot-1",
+  botRoleId: "role-1",
+  authorId: "u1",
+  authorIsBot: false,
+  mentionsEveryone: false,
+  mentions: Mention.make({ userIds: ["bot-1"], roleIds: [] }),
   content,
 })
 
@@ -80,28 +79,6 @@ const makeRouterLayer = (shouldRespond: boolean) =>
     }),
   )
 
-const makeAgent = (
-  session: SessionInfo,
-  send: (
-    session: SessionInfo,
-    text: string,
-  ) => Effect.Effect<string, OpenCodeClientError | SandboxDeadError | DatabaseError>,
-  prompts: Array<string>,
-): ThreadAgent => ({
-  threadId: session.threadId,
-  session,
-  current: () => Effect.succeed(session),
-  send: (text: string) =>
-    Effect.sync(() => {
-      prompts.push(text)
-      return { session, text }
-    }).pipe(
-      Effect.flatMap(({ session, text }) => send(session, text)),
-    ),
-  pause: () => Effect.void,
-  destroy: () => Effect.void,
-})
-
 const makeConversationLayer = (props: {
   events: ReadonlyArray<Inbound>
   tracked: Option.Option<SessionInfo>
@@ -115,8 +92,11 @@ const makeConversationLayer = (props: {
   shouldRespond?: boolean
   actions: Array<Action>
   prompts: Array<string>
+  recreateCalls?: Array<ThreadId>
 }) => {
   const resolveIndex = { value: 0 }
+  const state = new Map<string, SessionInfo>()
+  if (Option.isSome(props.tracked)) state.set(props.tracked.value.threadId, props.tracked.value)
 
   const inboxLayer = Layer.succeed(
     Inbox,
@@ -132,12 +112,12 @@ const makeConversationLayer = (props: {
         Effect.sync(() => {
           props.actions.push(action)
         }),
-      withTyping: <A, E, R>(thread_id: ThreadId, self: Effect.Effect<A, E, R>) =>
+      withTyping: <A, E, R>(threadId: ThreadId, self: Effect.Effect<A, E, R>) =>
         Effect.gen(function* () {
           props.actions.push(
             Typing.make({
               kind: "typing",
-              thread_id,
+              threadId,
             }),
           )
           return yield* self
@@ -145,21 +125,14 @@ const makeConversationLayer = (props: {
     }),
   )
 
-  const historyLayer = Layer.succeed(
-    History,
-    History.of({
-      rehydrate: props.rehydrate,
-    }),
-  )
-
   const threadsLayer = Layer.succeed(
     Threads,
     Threads.of({
       ensure: (event) => {
         if (event.kind === "thread_message") {
-          return Effect.succeed(ThreadRef.make({ thread_id: event.thread_id, channel_id: event.channel_id }))
+          return Effect.succeed(ThreadRef.make({ threadId: event.threadId, channelId: event.channelId }))
         }
-        return Effect.succeed(ThreadRef.make({ thread_id: ThreadId.make("t-new"), channel_id: event.channel_id }))
+        return Effect.succeed(ThreadRef.make({ threadId: ThreadId.make("t-new"), channelId: event.channelId }))
       },
     }),
   )
@@ -172,29 +145,69 @@ const makeConversationLayer = (props: {
     return session
   }
 
-  const poolLayer = Layer.succeed(
-    ThreadAgentPool,
-    ThreadAgentPool.of({
-      getOrCreate: (threadId, channelId, guildId) =>
+  const toThreadError = (threadId: ThreadId, cause: unknown): ThreadChatError => {
+    if (cause instanceof ThreadChatError) return cause
+    return ThreadChatError.make({
+      threadId,
+      cause,
+      retriable: Predicate.isTagged(cause, "SandboxDeadError") || Predicate.isTagged(cause, "OpenCodeClientError"),
+    })
+  }
+
+  const threadChatLayer = Layer.succeed(
+    ThreadChatCluster,
+    ThreadChatCluster.of({
+      send: (input) =>
+        Effect.sync(() => {
+          props.prompts.push(input.text)
+          const session = resolveSession(input.threadId, input.channelId, input.guildId)
+          return session
+        }).pipe(
+          Effect.flatMap((session) =>
+            props.send(session, input.text).pipe(
+              Effect.map((text) => {
+                const prev = state.get(input.threadId)
+                state.set(input.threadId, session)
+                return {
+                  text,
+                  session,
+                  changedSession: prev ? prev.sessionId !== session.sessionId : true,
+                }
+              }),
+              Effect.mapError((cause) => toThreadError(input.threadId, cause)),
+            )),
+        ),
+      status: (threadId) =>
+        Effect.succeed(
+          Option.fromNullable(
+            state.get(threadId) ?? SessionInfo.make({
+              threadId,
+              channelId: ChannelId.make("c1"),
+              guildId: GuildId.make("g1"),
+              sandboxId: SandboxId.make("sb1"),
+              sessionId: SessionId.make("s-tracked"),
+              previewUrl: "https://preview",
+              previewToken: null,
+              status: "active",
+              lastError: null,
+              resumeFailCount: 0,
+            }),
+          ),
+        ),
+      recreate: (threadId) =>
         Effect.sync(() => {
-          const session = resolveSession(threadId, channelId, guildId)
-          return makeAgent(session, props.send, props.prompts)
+          props.recreateCalls?.push(threadId)
+          state.delete(threadId)
         }),
-      hasTrackedThread: () => Effect.succeed(true),
-      getTrackedSession: () => Effect.succeed(props.tracked),
-      getActiveSessionCount: () => Effect.succeed(0),
-      pauseSession: () => Effect.void,
-      destroySession: () => Effect.void,
     }),
   )
 
   return Conversation.layer.pipe(
     Layer.provideMerge(inboxLayer),
     Layer.provideMerge(outboxLayer),
-    Layer.provideMerge(historyLayer),
-    Layer.provideMerge(ConversationLedger.noop),
+    Layer.provideMerge(IngressDedup.noop),
     Layer.provideMerge(threadsLayer),
-    Layer.provideMerge(poolLayer),
+    Layer.provideMerge(threadChatLayer),
     Layer.provideMerge(makeRouterLayer(props.shouldRespond ?? true)),
     Layer.provideMerge(testConfigLayer),
   )
@@ -223,12 +236,12 @@ describe("Conversation", () => {
       const sent = actions[1]
       if (!sent) throw new Error("missing send action")
       expect(sent.kind).toBe("send")
-      expect(sent.thread_id).toBe(ThreadId.make("t1"))
+      expect(sent.threadId).toBe(ThreadId.make("t1"))
       if (sent.kind === "send") expect(sent.text).toBe("echo:hello")
     }).pipe(Effect.provide(live))
   })
 
-  effectTest("turn rehydrates prompt when tracked and resolved sessions differ", () => {
+  effectTest("turn sends thread prompt through ThreadChatCluster", () => {
     const actions: Array<Action> = []
     const prompts: Array<string> = []
     const live = makeConversationLayer({
@@ -245,17 +258,17 @@ describe("Conversation", () => {
       const conversation = yield* Conversation
       yield* conversation.turn(makeEvent("help me"))
 
-      expect(prompts).toEqual(["rehydrated:help me"])
+      expect(prompts).toEqual(["help me"])
       expect(actions.map((x) => x.kind)).toEqual(["typing", "send"])
       const sent = actions[1]
       if (!sent) throw new Error("missing send action")
       expect(sent.kind).toBe("send")
-      expect(sent.thread_id).toBe(ThreadId.make("t1"))
+      expect(sent.threadId).toBe(ThreadId.make("t1"))
       if (sent.kind === "send") expect(sent.text).toBe("ok")
     }).pipe(Effect.provide(live))
   })
 
-  effectTest("turn recovers from dead sandbox by re-resolving and retrying", () => {
+  effectTest("turn returns retriable error for dead sandbox from ThreadChatCluster", () => {
     const actions: Array<Action> = []
     const prompts: Array<string> = []
     const calls = { value: 0 }
@@ -283,21 +296,11 @@ describe("Conversation", () => {
 
     return Effect.gen(function* () {
       const conversation = yield* Conversation
-      yield* conversation.turn(makeEvent("fix build"))
-
-      expect(prompts).toEqual(["fix build", "rehydrated:fix build"])
-      expect(actions.map((x) => x.kind)).toEqual(["typing", "send", "send"])
-      const recovery = actions[1]
-      if (!recovery) throw new Error("missing recovery action")
-      expect(recovery.kind).toBe("send")
-      expect(recovery.thread_id).toBe(ThreadId.make("t1"))
-      if (recovery.kind === "send") expect(recovery.text).toBe("*Session changed state, recovering...*")
+      const out = yield* conversation.turn(makeEvent("fix build")).pipe(Effect.either)
 
-      const sent = actions[2]
-      if (!sent) throw new Error("missing final action")
-      expect(sent.kind).toBe("send")
-      expect(sent.thread_id).toBe(ThreadId.make("t1"))
-      if (sent.kind === "send") expect(sent.text).toBe("ok:rehydrated:fix build")
+      expect(out._tag).toBe("Left")
+      expect(prompts).toEqual(["fix build"])
+      expect(actions.map((x) => x.kind)).toEqual(["typing"])
     }).pipe(Effect.provide(live))
   })
 
@@ -323,11 +326,65 @@ describe("Conversation", () => {
       const sent = actions[1]
       if (!sent) throw new Error("missing send action")
       expect(sent.kind).toBe("send")
-      expect(sent.thread_id).toBe(ThreadId.make("t-new"))
+      expect(sent.threadId).toBe(ThreadId.make("t-new"))
       if (sent.kind === "send") expect(sent.text).toBe("echo:from channel")
     }).pipe(Effect.provide(live))
   })
 
+  effectTest("!status is handled as a command without sending to agent", () => {
+    const actions: Array<Action> = []
+    const prompts: Array<string> = []
+    const live = makeConversationLayer({
+      events: [],
+      tracked: Option.some(makeSession("s1")),
+      resolves: [makeSession("s1")],
+      send: (_session, text) => Effect.succeed(`echo:${text}`),
+      rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
+      actions,
+      prompts,
+    })
+
+    return Effect.gen(function* () {
+      const conversation = yield* Conversation
+      yield* conversation.turn(makeEvent("!status"))
+
+      expect(prompts).toEqual([])
+      expect(actions.filter((action) => action.kind === "send").length).toBe(1)
+      const sent = actions.find((action) => action.kind === "send")
+      if (!sent || sent.kind !== "send") throw new Error("missing status output")
+      expect(sent.text.includes("**Status:**")).toBe(true)
+      expect(sent.text.includes("**Sandbox:**")).toBe(true)
+      expect(sent.text.includes("**Session:**")).toBe(true)
+    }).pipe(Effect.provide(live))
+  })
+
+  effectTest("!recreate invokes recreate command and does not call agent", () => {
+    const actions: Array<Action> = []
+    const prompts: Array<string> = []
+    const recreateCalls: Array<ThreadId> = []
+    const live = makeConversationLayer({
+      events: [],
+      tracked: Option.some(makeSession("s1")),
+      resolves: [makeSession("s1")],
+      send: (_session, text) => Effect.succeed(`echo:${text}`),
+      rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
+      actions,
+      prompts,
+      recreateCalls,
+    })
+
+    return Effect.gen(function* () {
+      const conversation = yield* Conversation
+      yield* conversation.turn(makeEvent("!recreate"))
+
+      expect(prompts).toEqual([])
+      expect(recreateCalls).toEqual([ThreadId.make("t1")])
+      const sent = actions.find((action) => action.kind === "send")
+      if (!sent || sent.kind !== "send") throw new Error("missing recreate output")
+      expect(sent.text.includes("Session recreated")).toBe(true)
+    }).pipe(Effect.provide(live))
+  })
+
   effectTest("run retries retriable failures in-process", () => {
     const actions: Array<Action> = []
     const prompts: Array<string> = []
@@ -340,11 +397,15 @@ describe("Conversation", () => {
         Effect.gen(function* () {
           calls.value += 1
           if (calls.value === 1) {
-            return yield* Effect.fail(new OpenCodeClientError({
+            return yield* new OpenCodeClientError({
               operation: "sendPrompt",
               statusCode: 502,
               body: "StatusCode: non 2xx status code (502 POST https://proxy.daytona.works/session/s1/message)",
-            }))
+              kind: classifyOpenCodeFailure(
+                502,
+                "StatusCode: non 2xx status code (502 POST https://proxy.daytona.works/session/s1/message)",
+              ),
+            })
           }
           return `ok:${text}`
         }),
@@ -362,7 +423,7 @@ describe("Conversation", () => {
       const sent = actions[2]
       if (!sent) throw new Error("missing send action")
       expect(sent.kind).toBe("send")
-      expect(sent.thread_id).toBe(ThreadId.make("t1"))
+      expect(sent.threadId).toBe(ThreadId.make("t1"))
       if (sent.kind === "send") expect(sent.text).toBe("ok:retry now")
     }).pipe(Effect.provide(live))
   })
@@ -392,7 +453,7 @@ describe("Conversation", () => {
       const sent = actions[1]
       if (!sent) throw new Error("missing send action")
       expect(sent.kind).toBe("send")
-      expect(sent.thread_id).toBe(ThreadId.make("t1"))
+      expect(sent.threadId).toBe(ThreadId.make("t1"))
       if (sent.kind === "send") {
         expect(sent.text).toBe("Something went wrong. Please try again in a moment.")
         expect(sent.text.includes("proxy.daytona.works")).toBe(false)
@@ -447,27 +508,25 @@ describe("Conversation", () => {
 
 // --- Duplicate processing tests ---
 
-/** A ledger that tracks dedup calls */
-const makeTrackingLedger = () => {
+/** A dedup service that tracks dedup calls */
+const makeTrackingDedup = () => {
   const seen = new Set<string>()
   const dedupCalls: Array<string> = []
 
-  const service: ConversationLedger.Service = {
-    dedup: (message_id) =>
+  const service: IngressDedup.Service = {
+    dedup: (messageId) =>
       Effect.sync(() => {
-        dedupCalls.push(message_id)
-        if (seen.has(message_id)) return false
-        seen.add(message_id)
+        dedupCalls.push(messageId)
+        if (seen.has(messageId)) return false
+        seen.add(messageId)
         return true
       }),
-    getOffset: () => Effect.succeed(Option.none()),
-    setOffset: () => Effect.void,
   }
 
   return { service, seen, dedupCalls }
 }
 
-const makeConversationLayerWithLedger = (props: {
+const makeConversationLayerWithDedup = (props: {
   events: ReadonlyArray<Inbound>
   tracked: Option.Option<SessionInfo>
   resolves: ReadonlyArray<SessionInfo>
@@ -480,9 +539,11 @@ const makeConversationLayerWithLedger = (props: {
   shouldRespond?: boolean
   actions: Array<Action>
   prompts: Array<string>
-  ledger: ConversationLedger.Service
+  dedup: IngressDedup.Service
 }) => {
   const resolveIndex = { value: 0 }
+  const state = new Map<string, SessionInfo>()
+  if (Option.isSome(props.tracked)) state.set(props.tracked.value.threadId, props.tracked.value)
 
   const inboxLayer = Layer.succeed(
     Inbox,
@@ -498,12 +559,12 @@ const makeConversationLayerWithLedger = (props: {
         Effect.sync(() => {
           props.actions.push(action)
         }),
-      withTyping: <A, E, R>(thread_id: ThreadId, self: Effect.Effect<A, E, R>) =>
+      withTyping: <A, E, R>(threadId: ThreadId, self: Effect.Effect<A, E, R>) =>
         Effect.gen(function* () {
           props.actions.push(
             Typing.make({
               kind: "typing",
-              thread_id,
+              threadId,
             }),
           )
           return yield* self
@@ -511,21 +572,14 @@ const makeConversationLayerWithLedger = (props: {
     }),
   )
 
-  const historyLayer = Layer.succeed(
-    History,
-    History.of({
-      rehydrate: props.rehydrate,
-    }),
-  )
-
   const threadsLayer = Layer.succeed(
     Threads,
     Threads.of({
       ensure: (event) => {
         if (event.kind === "thread_message") {
-          return Effect.succeed(ThreadRef.make({ thread_id: event.thread_id, channel_id: event.channel_id }))
+          return Effect.succeed(ThreadRef.make({ threadId: event.threadId, channelId: event.channelId }))
         }
-        return Effect.succeed(ThreadRef.make({ thread_id: ThreadId.make("t-new"), channel_id: event.channel_id }))
+        return Effect.succeed(ThreadRef.make({ threadId: ThreadId.make("t-new"), channelId: event.channelId }))
       },
     }),
   )
@@ -538,31 +592,70 @@ const makeConversationLayerWithLedger = (props: {
     return session
   }
 
-  const poolLayer = Layer.succeed(
-    ThreadAgentPool,
-    ThreadAgentPool.of({
-      getOrCreate: (threadId, channelId, guildId) =>
+  const toThreadError = (threadId: ThreadId, cause: unknown): ThreadChatError => {
+    if (cause instanceof ThreadChatError) return cause
+    return ThreadChatError.make({
+      threadId,
+      cause,
+      retriable: Predicate.isTagged(cause, "SandboxDeadError") || Predicate.isTagged(cause, "OpenCodeClientError"),
+    })
+  }
+
+  const threadChatLayer = Layer.succeed(
+    ThreadChatCluster,
+    ThreadChatCluster.of({
+      send: (input) =>
+        Effect.sync(() => {
+          props.prompts.push(input.text)
+          const session = resolveSession(input.threadId, input.channelId, input.guildId)
+          return session
+        }).pipe(
+          Effect.flatMap((session) =>
+            props.send(session, input.text).pipe(
+              Effect.map((text) => {
+                const prev = state.get(input.threadId)
+                state.set(input.threadId, session)
+                return {
+                  text,
+                  session,
+                  changedSession: prev ? prev.sessionId !== session.sessionId : true,
+                }
+              }),
+              Effect.mapError((cause) => toThreadError(input.threadId, cause)),
+            )),
+        ),
+      status: (threadId) =>
+        Effect.succeed(
+          Option.fromNullable(
+            state.get(threadId) ?? SessionInfo.make({
+              threadId,
+              channelId: ChannelId.make("c1"),
+              guildId: GuildId.make("g1"),
+              sandboxId: SandboxId.make("sb1"),
+              sessionId: SessionId.make("s-tracked"),
+              previewUrl: "https://preview",
+              previewToken: null,
+              status: "active",
+              lastError: null,
+              resumeFailCount: 0,
+            }),
+          ),
+        ),
+      recreate: (threadId) =>
         Effect.sync(() => {
-          const session = resolveSession(threadId, channelId, guildId)
-          return makeAgent(session, props.send, props.prompts)
+          state.delete(threadId)
         }),
-      hasTrackedThread: () => Effect.succeed(true),
-      getTrackedSession: () => Effect.succeed(props.tracked),
-      getActiveSessionCount: () => Effect.succeed(0),
-      pauseSession: () => Effect.void,
-      destroySession: () => Effect.void,
     }),
   )
 
-  const ledgerLayer = Layer.succeed(ConversationLedger, ConversationLedger.of(props.ledger))
+  const dedupLayer = Layer.succeed(IngressDedup, IngressDedup.of(props.dedup))
 
   return Conversation.layer.pipe(
     Layer.provideMerge(inboxLayer),
     Layer.provideMerge(outboxLayer),
-    Layer.provideMerge(historyLayer),
-    Layer.provideMerge(ledgerLayer),
+    Layer.provideMerge(dedupLayer),
     Layer.provideMerge(threadsLayer),
-    Layer.provideMerge(poolLayer),
+    Layer.provideMerge(threadChatLayer),
     Layer.provideMerge(makeRouterLayer(props.shouldRespond ?? true)),
     Layer.provideMerge(testConfigLayer),
   )
@@ -572,10 +665,10 @@ describe("Conversation duplicate processing", () => {
   effectTest("same message_id queued twice via run is only sent once", () => {
     const actions: Array<Action> = []
     const prompts: Array<string> = []
-    const ledger = makeTrackingLedger()
+    const dedup = makeTrackingDedup()
     const event = makeEvent("hello")
 
-    const live = makeConversationLayerWithLedger({
+    const live = makeConversationLayerWithDedup({
       // Feed the same event twice to simulate catch-up + real-time race
       events: [event, event],
       tracked: Option.none(),
@@ -584,7 +677,7 @@ describe("Conversation duplicate processing", () => {
       rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
       actions,
       prompts,
-      ledger: ledger.service,
+      dedup: dedup.service,
     })
 
     return Effect.gen(function* () {
@@ -592,7 +685,7 @@ describe("Conversation duplicate processing", () => {
       yield* conversation.run
 
       // The ledger should have been called twice with dedup
-      expect(ledger.dedupCalls).toEqual(["m1", "m1"])
+      expect(dedup.dedupCalls).toEqual(["m1", "m1"])
       // The agent should have received the prompt only once
       expect(prompts).toEqual(["hello"])
       // Only one typing + one send
@@ -600,12 +693,12 @@ describe("Conversation duplicate processing", () => {
     }).pipe(Effect.provide(live))
   })
 
-  effectTest("noop ledger deduplicates", () => {
+  effectTest("noop dedup service deduplicates", () => {
     const actions: Array<Action> = []
     const prompts: Array<string> = []
     const event = makeEvent("hello")
 
-    // The noop ledger tracks seen message_ids
+    // The noop dedup service tracks seen message_ids
     const live = makeConversationLayer({
       events: [event, event],
       tracked: Option.none(),
@@ -626,13 +719,13 @@ describe("Conversation duplicate processing", () => {
     }).pipe(Effect.provide(live))
   })
 
-  effectTest("turn called twice with same event only processes once with tracking ledger", () => {
+  effectTest("turn called twice with same event only processes once with tracking dedup service", () => {
     const actions: Array<Action> = []
     const prompts: Array<string> = []
-    const ledger = makeTrackingLedger()
+    const dedup = makeTrackingDedup()
     const event = makeEvent("help me")
 
-    const live = makeConversationLayerWithLedger({
+    const live = makeConversationLayerWithDedup({
       events: [],
       tracked: Option.none(),
       resolves: [makeSession("s1")],
@@ -640,7 +733,7 @@ describe("Conversation duplicate processing", () => {
       rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
       actions,
       prompts,
-      ledger: ledger.service,
+      dedup: dedup.service,
     })
 
     return Effect.gen(function* () {
@@ -657,11 +750,11 @@ describe("Conversation duplicate processing", () => {
   effectTest("two different messages on same thread are processed sequentially (not lost)", () => {
     const actions: Array<Action> = []
     const prompts: Array<string> = []
-    const ledger = makeTrackingLedger()
+    const dedup = makeTrackingDedup()
     const event1 = makeThreadEvent({ threadId: "t1", channelId: "c1", messageId: "m1", content: "first" })
     const event2 = makeThreadEvent({ threadId: "t1", channelId: "c1", messageId: "m2", content: "second" })
 
-    const live = makeConversationLayerWithLedger({
+    const live = makeConversationLayerWithDedup({
       events: [event1, event2],
       tracked: Option.none(),
       resolves: [makeSession("s1")],
@@ -669,7 +762,7 @@ describe("Conversation duplicate processing", () => {
       rehydrate: (_threadId, latest) => Effect.succeed(`rehydrated:${latest}`),
       actions,
       prompts,
-      ledger: ledger.service,
+      dedup: dedup.service,
     })
 
     return Effect.gen(function* () {

+ 270 - 0
packages/discord/src/conversation/conversation.ts

@@ -0,0 +1,270 @@
+import { Context, Effect, Layer, Option, Predicate, Schedule, Stream } from "effect"
+import { ThreadChatCluster, ThreadChatError } from "./thread/cluster"
+import { AppConfig } from "../config"
+import { TurnRouter } from "../discord/turn-routing"
+import type { HealthCheckError, OpenCodeClientError, SandboxDeadError, SandboxStartError } from "../errors"
+import { ActorMap } from "../lib/actors/keyed"
+import type { ChannelId, SessionInfo, ThreadId } from "../types"
+import { type ConversationError, messageOf, RoutingError, SandboxSendError } from "./model/errors"
+import { Send, type Inbound } from "./model/schema"
+import { Inbox } from "./inbox"
+import { IngressDedup } from "./dedup"
+import { Outbox } from "./outbox"
+import { Threads } from "./threads"
+
+export declare namespace Conversation {
+  export interface Service {
+    readonly turn: (event: Inbound) => Effect.Effect<void, ConversationError>
+    readonly run: Effect.Effect<void>
+  }
+}
+
+export class Conversation extends Context.Tag("@discord/conversation/Conversation")<
+  Conversation,
+  Conversation.Service
+>() {
+  static readonly layer = Layer.scoped(
+    Conversation,
+    Effect.gen(function* () {
+      const inbox = yield* Inbox
+      const outbox = yield* Outbox
+      const threads = yield* Threads
+      const dedup = yield* IngressDedup
+      const config = yield* AppConfig
+      const threadChat = yield* ThreadChatCluster
+      const router = yield* TurnRouter
+      const actors = yield* ActorMap.make<string>()
+      const RETRY_MESSAGE = "Something went wrong. Please try again in a moment."
+      const turnRetry = Schedule.exponential("500 millis").pipe(
+        Schedule.intersect(Schedule.recurs(2)),
+        Schedule.whileInput((error: ConversationError) => error.retriable),
+      )
+
+      type RetriableCause = SandboxDeadError | OpenCodeClientError | HealthCheckError | SandboxStartError
+
+      const isRetriableCause = (cause: unknown): cause is RetriableCause =>
+        Predicate.isTagged(cause, "SandboxDeadError") ||
+        Predicate.isTagged(cause, "OpenCodeClientError") ||
+        Predicate.isTagged(cause, "HealthCheckError") ||
+        Predicate.isTagged(cause, "SandboxStartError")
+
+      const asSendError =
+        (threadId: ThreadId) =>
+        (cause: unknown): SandboxSendError => {
+          if (cause instanceof ThreadChatError) {
+            return SandboxSendError.make({
+              threadId,
+              message: messageOf(cause.cause),
+              retriable: cause.retriable,
+            })
+          }
+          return SandboxSendError.make({
+            threadId,
+            message: messageOf(cause),
+            retriable: isRetriableCause(cause),
+          })
+        }
+
+      const publishText = (threadId: ThreadId, text: string) =>
+        outbox.publish(
+          Send.make({
+            kind: "send",
+            threadId,
+            text,
+          }),
+        )
+
+      const renderStatus = (session: SessionInfo) => {
+        const model = config.openCodeModel.replace("opencode/", "")
+        return [
+          `**Status:** ${session.status}`,
+          `**Model:** \`${model}\``,
+          `**Sandbox:** \`${session.sandboxId}\``,
+          `**Session:** \`${session.sessionId}\``,
+          session.resumeFailCount > 0 ? `**Resume failures:** ${session.resumeFailCount}` : null,
+          session.lastError ? `**Last error:** ${session.lastError.slice(0, 200)}` : null,
+        ]
+          .filter((line): line is string => line !== null)
+          .join("\n")
+      }
+
+      const reportFailure = (threadId: ThreadId) => (error: ConversationError) => {
+        if (error.retriable) return Effect.fail(error)
+        return publishText(threadId, RETRY_MESSAGE).pipe(
+          Effect.catchAll(() => Effect.void),
+          Effect.zipRight(Effect.fail(error)),
+        )
+      }
+
+      const route = Effect.fn("Conversation.route")(function* (event: Inbound) {
+        if (event.authorIsBot) return false
+        if (event.mentionsEveryone) return false
+        if (!event.content.trim()) return false
+
+        const mentioned =
+          event.mentions.userIds.includes(event.botUserId) ||
+          (event.botRoleId.length > 0 && event.mentions.roleIds.includes(event.botRoleId))
+        if (event.kind === "channel_message") return mentioned
+        if (mentioned) return true
+
+        const owned = yield* threadChat
+          .status(event.threadId)
+          .pipe(
+            Effect.map((session) => Option.isSome(session)),
+            Effect.mapError(asSendError(event.threadId)),
+          )
+        if (!owned) return false
+
+        const decision = yield* router
+          .shouldRespond({
+            content: event.content,
+            botUserId: event.botUserId,
+            botRoleId: event.botRoleId,
+            mentionedUserIds: event.mentions.userIds,
+            mentionedRoleIds: event.mentions.roleIds,
+          })
+          .pipe(
+            Effect.mapError((cause) =>
+              RoutingError.make({
+                message: messageOf(cause),
+                retriable: false,
+              }),
+            ),
+          )
+        return decision.shouldRespond
+      })
+
+      const resolve = Effect.fn("Conversation.resolve")(function* (event: Inbound) {
+        if (event.kind === "thread_message") {
+          return { threadId: event.threadId, channelId: event.channelId }
+        }
+        const name = yield* router.generateThreadName(event.content)
+        return yield* threads.ensure(event, name)
+      })
+
+      const commandStatus = (threadId: ThreadId) =>
+        Effect.gen(function* () {
+          const tracked = yield* threadChat
+            .status(threadId)
+            .pipe(Effect.catchAll(() => Effect.succeed(Option.none())))
+          if (Option.isNone(tracked)) {
+            yield* publishText(threadId, "*No active session for this thread.*")
+            return
+          }
+          yield* publishText(threadId, renderStatus(tracked.value))
+        })
+
+      const commandRecreate = (threadId: ThreadId) =>
+        threadChat.recreate(threadId).pipe(
+          Effect.catchAll(() => Effect.void),
+          Effect.zipRight(
+            publishText(threadId, "*☠️ Session recreated. Next message will provision a fresh sandbox.*"),
+          ),
+        )
+
+      const commands = {
+        "!status": commandStatus,
+        "!reset": commandRecreate,
+        "!recreate": commandRecreate,
+      } as const
+      type CommandName = keyof typeof commands
+      const isCommand = (text: string): text is CommandName => text in commands
+
+      const command = (event: Inbound, target: { threadId: ThreadId; channelId: ChannelId }) =>
+        Effect.gen(function* () {
+          const text = event.content.trim().toLowerCase()
+          if (!isCommand(text)) return false
+          yield* commands[text](target.threadId)
+          return true
+        })
+
+      const turnRaw = Effect.fn("Conversation.turnRaw")(function* (event: Inbound) {
+        if (!(yield* route(event))) return
+
+        const target = yield* resolve(event)
+
+        if (yield* command(event, target)) return
+
+        yield* Effect.logInfo("User message").pipe(
+          Effect.annotateLogs({
+            event: "conversation.user.message",
+            thread_id: target.threadId,
+            author_id: event.authorId,
+            content: event.content.slice(0, 200),
+          }),
+        )
+
+        yield* outbox
+          .withTyping(
+            target.threadId,
+            Effect.gen(function* () {
+              const reply = yield* threadChat.send({
+                threadId: target.threadId,
+                channelId: target.channelId,
+                guildId: event.guildId,
+                messageId: event.messageId,
+                text: event.content,
+              }).pipe(
+                Effect.map((out) => out.text),
+                Effect.mapError(asSendError(target.threadId)),
+              )
+
+              yield* Effect.logInfo("Bot reply").pipe(
+                Effect.annotateLogs({
+                  event: "conversation.bot.reply",
+                  thread_id: target.threadId,
+                  content: reply.slice(0, 200),
+                }),
+              )
+              yield* publishText(target.threadId, reply)
+            }),
+          )
+          .pipe(Effect.catchAll(reportFailure(target.threadId)))
+      })
+
+      const keyOf = (event: Inbound) =>
+        event.kind === "thread_message" ? `thread:${event.threadId}` : `channel:${event.channelId}`
+
+      const processEvent = (event: Inbound) => actors.run(keyOf(event), turnRaw(event), { touch: false })
+      const processFresh = (event: Inbound) =>
+        dedup.dedup(event.messageId).pipe(
+          Effect.flatMap((fresh) => {
+            if (!fresh) return Effect.void
+            return processEvent(event)
+          }),
+        )
+
+      const turn = Effect.fn("Conversation.turn")(function* (event: Inbound) {
+        yield* processFresh(event)
+      })
+
+      const run = inbox.events.pipe(
+        Stream.mapEffect(
+          (event) =>
+            dedup.dedup(event.messageId).pipe(
+              Effect.flatMap((fresh) => {
+                if (!fresh) return Effect.void
+                return processEvent(event).pipe(
+                  Effect.retry(turnRetry),
+                  Effect.catchAll((error) =>
+                    Effect.logError("Conversation turn failed").pipe(
+                      Effect.annotateLogs({
+                        event: "conversation.turn.failed",
+                        tag: error._tag,
+                        retriable: error.retriable,
+                        message: error.message,
+                      }),
+                    ),
+                  ),
+                )
+              }),
+            ),
+          { concurrency: "unbounded", unordered: true },
+        ),
+        Stream.runDrain,
+      )
+
+      return Conversation.of({ turn, run })
+    }),
+  )
+}

+ 25 - 0
packages/discord/src/conversation/dedup.test.ts

@@ -0,0 +1,25 @@
+import { describe, expect } from "bun:test"
+import { Effect } from "effect"
+import { effectTest } from "../test/effect"
+import { IngressDedup } from "./dedup"
+
+describe("IngressDedup", () => {
+  effectTest("dedup returns true first time, false second time", () =>
+    Effect.gen(function* () {
+      const dedup = yield* IngressDedup
+      expect(yield* dedup.dedup("m1")).toBe(true)
+      expect(yield* dedup.dedup("m1")).toBe(false)
+      expect(yield* dedup.dedup("m2")).toBe(true)
+      expect(yield* dedup.dedup("m2")).toBe(false)
+    }).pipe(Effect.provide(IngressDedup.noop)),
+  )
+
+  effectTest("layer mode behaves as memory dedup", () =>
+    Effect.gen(function* () {
+      const dedup = yield* IngressDedup
+      expect(yield* dedup.dedup("m1")).toBe(true)
+      expect(yield* dedup.dedup("m1")).toBe(false)
+      expect(yield* dedup.dedup("m2")).toBe(true)
+    }).pipe(Effect.provide(IngressDedup.layer)),
+  )
+})

+ 35 - 0
packages/discord/src/conversation/dedup.ts

@@ -0,0 +1,35 @@
+import { Context, Effect, Layer } from "effect"
+
+const DEDUP_LIMIT = 4_000
+
+const makeDedupSet = () => {
+  const seen = new Set<string>()
+  const order: Array<string> = []
+  return (messageId: string): boolean => {
+    if (seen.has(messageId)) return false
+    seen.add(messageId)
+    order.push(messageId)
+    if (order.length > DEDUP_LIMIT) {
+      const oldest = order.shift()
+      if (oldest) seen.delete(oldest)
+    }
+    return true
+  }
+}
+
+export declare namespace IngressDedup {
+  export interface Service {
+    readonly dedup: (messageId: string) => Effect.Effect<boolean>
+  }
+}
+
+export class IngressDedup extends Context.Tag("@discord/conversation/IngressDedup")<IngressDedup, IngressDedup.Service>() {
+  static readonly noop = Layer.sync(IngressDedup, () => {
+    const check = makeDedupSet()
+    return IngressDedup.of({
+      dedup: (messageId) => Effect.sync(() => check(messageId)),
+    })
+  })
+
+  static readonly layer = IngressDedup.noop
+}

+ 4 - 4
packages/discord/src/conversation/services/history.ts → packages/discord/src/conversation/history.ts

@@ -1,10 +1,10 @@
 import { Context, Effect, Layer } from "effect"
-import type { ThreadId } from "../../types"
-import type { HistoryError } from "../model/errors"
+import type { ThreadId } from "../types"
+import type { HistoryError } from "./model/errors"
 
 export declare namespace History {
   export interface Service {
-    readonly rehydrate: (thread_id: ThreadId, latest: string) => Effect.Effect<string, HistoryError>
+    readonly rehydrate: (threadId: ThreadId, latest: string) => Effect.Effect<string, HistoryError>
   }
 }
 
@@ -12,7 +12,7 @@ export class History extends Context.Tag("@discord/conversation/History")<Histor
   static readonly passthrough = Layer.succeed(
     History,
     History.of({
-      rehydrate: (_thread_id: ThreadId, latest: string) => Effect.succeed(latest),
+      rehydrate: (_threadId: ThreadId, latest: string) => Effect.succeed(latest),
     }),
   )
 }

+ 1 - 1
packages/discord/src/conversation/services/inbox.ts → packages/discord/src/conversation/inbox.ts

@@ -1,5 +1,5 @@
 import { Context, Layer, Stream } from "effect"
-import type { Inbound } from "../model/schema"
+import type { Inbound } from "./model/schema"
 
 export declare namespace Inbox {
   export interface Service {

+ 2 - 1
packages/discord/src/conversation/services/index.ts → packages/discord/src/conversation/index.ts

@@ -1,6 +1,7 @@
 export { Conversation } from "./conversation"
 export { History } from "./history"
 export { Inbox } from "./inbox"
-export { ConversationLedger } from "./ledger"
+export { IngressDedup } from "./dedup"
+export { OffsetStore } from "./offsets"
 export { Outbox } from "./outbox"
 export { Threads } from "./threads"

+ 4 - 4
packages/discord/src/conversation/model/errors.ts

@@ -6,7 +6,7 @@ const DeliveryAction = Schema.Literal("typing", "send", "reply")
 export class ThreadEnsureError extends Schema.TaggedError<ThreadEnsureError>()(
   "ThreadEnsureError",
   {
-    channel_id: ChannelId,
+    channelId: ChannelId,
     message: Schema.String,
     retriable: Schema.Boolean,
   },
@@ -15,7 +15,7 @@ export class ThreadEnsureError extends Schema.TaggedError<ThreadEnsureError>()(
 export class HistoryError extends Schema.TaggedError<HistoryError>()(
   "HistoryError",
   {
-    thread_id: ThreadId,
+    threadId: ThreadId,
     message: Schema.String,
     retriable: Schema.Boolean,
   },
@@ -24,7 +24,7 @@ export class HistoryError extends Schema.TaggedError<HistoryError>()(
 export class DeliveryError extends Schema.TaggedError<DeliveryError>()(
   "DeliveryError",
   {
-    thread_id: ThreadId,
+    threadId: ThreadId,
     action: DeliveryAction,
     message: Schema.String,
     retriable: Schema.Boolean,
@@ -42,7 +42,7 @@ export class RoutingError extends Schema.TaggedError<RoutingError>()(
 export class SandboxSendError extends Schema.TaggedError<SandboxSendError>()(
   "SandboxSendError",
   {
-    thread_id: ThreadId,
+    threadId: ThreadId,
     message: Schema.String,
     retriable: Schema.Boolean,
   },

+ 24 - 24
packages/discord/src/conversation/model/schema.ts

@@ -2,35 +2,35 @@ import { Schema } from "effect"
 import { ThreadId, ChannelId, GuildId } from "../../types"
 
 export class Mention extends Schema.Class<Mention>("Mention")({
-  user_ids: Schema.Array(Schema.String),
-  role_ids: Schema.Array(Schema.String),
+  userIds: Schema.Array(Schema.String),
+  roleIds: Schema.Array(Schema.String),
 }) {}
 
 export class ThreadMessage extends Schema.Class<ThreadMessage>("ThreadMessage")({
   kind: Schema.Literal("thread_message"),
-  thread_id: ThreadId,
-  channel_id: ChannelId,
-  message_id: Schema.String,
-  guild_id: GuildId,
-  bot_user_id: Schema.String,
-  bot_role_id: Schema.String,
-  author_id: Schema.String,
-  author_is_bot: Schema.Boolean,
-  mentions_everyone: Schema.Boolean,
+  threadId: ThreadId,
+  channelId: ChannelId,
+  messageId: Schema.String,
+  guildId: GuildId,
+  botUserId: Schema.String,
+  botRoleId: Schema.String,
+  authorId: Schema.String,
+  authorIsBot: Schema.Boolean,
+  mentionsEveryone: Schema.Boolean,
   mentions: Mention,
   content: Schema.String,
 }) {}
 
 export class ChannelMessage extends Schema.Class<ChannelMessage>("ChannelMessage")({
   kind: Schema.Literal("channel_message"),
-  channel_id: ChannelId,
-  message_id: Schema.String,
-  guild_id: GuildId,
-  bot_user_id: Schema.String,
-  bot_role_id: Schema.String,
-  author_id: Schema.String,
-  author_is_bot: Schema.Boolean,
-  mentions_everyone: Schema.Boolean,
+  channelId: ChannelId,
+  messageId: Schema.String,
+  guildId: GuildId,
+  botUserId: Schema.String,
+  botRoleId: Schema.String,
+  authorId: Schema.String,
+  authorIsBot: Schema.Boolean,
+  mentionsEveryone: Schema.Boolean,
   mentions: Mention,
   content: Schema.String,
 }) {}
@@ -43,25 +43,25 @@ export const Inbound = Schema.Union(
 export type Inbound = typeof Inbound.Type
 
 export class ThreadRef extends Schema.Class<ThreadRef>("ThreadRef")({
-  thread_id: ThreadId,
-  channel_id: ChannelId,
+  threadId: ThreadId,
+  channelId: ChannelId,
 }) {}
 
 export class Send extends Schema.Class<Send>("Send")({
   kind: Schema.Literal("send"),
-  thread_id: ThreadId,
+  threadId: ThreadId,
   text: Schema.String,
 }) {}
 
 export class Reply extends Schema.Class<Reply>("Reply")({
   kind: Schema.Literal("reply"),
-  thread_id: ThreadId,
+  threadId: ThreadId,
   text: Schema.String,
 }) {}
 
 export class Typing extends Schema.Class<Typing>("Typing")({
   kind: Schema.Literal("typing"),
-  thread_id: ThreadId,
+  threadId: ThreadId,
 }) {}
 
 export const Action = Schema.Union(

+ 74 - 0
packages/discord/src/conversation/offsets.test.ts

@@ -0,0 +1,74 @@
+import { describe, expect } from "bun:test"
+import { Duration, Effect, Layer, Option, Redacted } from "effect"
+import { AppConfig } from "../config"
+import { SqliteDb } from "../db/client"
+import { effectTest, withTempSqliteFile } from "../test/effect"
+import { OffsetStore } from "./offsets"
+
+const makeConfig = (databasePath: string) =>
+  AppConfig.of({
+    discordToken: Redacted.make("token"),
+    allowedChannelIds: [],
+    discordCategoryId: "",
+    discordRoleId: "",
+    discordRequiredRoleId: "",
+    discordCommandGuildId: "",
+    databasePath,
+    daytonaApiKey: Redacted.make("daytona"),
+    daytonaSnapshot: "",
+    openCodeZenApiKey: Redacted.make("zen"),
+    githubToken: "",
+    logLevel: "info",
+    healthHost: "127.0.0.1",
+    healthPort: 8787,
+    turnRoutingMode: "off",
+    turnRoutingModel: "claude-haiku-4-5",
+    sandboxReusePolicy: "resume_preferred",
+    sandboxTimeout: Duration.minutes(30),
+    cleanupInterval: Duration.minutes(5),
+    staleActiveGraceMinutes: 5 as AppConfig.Service["staleActiveGraceMinutes"],
+    pausedTtlMinutes: 180 as AppConfig.Service["pausedTtlMinutes"],
+    activeHealthCheckTimeoutMs: 15000 as AppConfig.Service["activeHealthCheckTimeoutMs"],
+    startupHealthTimeoutMs: 120000 as AppConfig.Service["startupHealthTimeoutMs"],
+    resumeHealthTimeoutMs: 120000 as AppConfig.Service["resumeHealthTimeoutMs"],
+    sandboxCreationTimeout: 180 as AppConfig.Service["sandboxCreationTimeout"],
+    openCodeModel: "opencode/claude-sonnet-4-5",
+  })
+
+const withOffsets = <A, E, R>(
+  run: (offsets: OffsetStore.Service) => Effect.Effect<A, E, R>,
+) =>
+  withTempSqliteFile((databasePath) =>
+    Effect.gen(function* () {
+      const config = Layer.succeed(AppConfig, makeConfig(databasePath))
+      const sqlite = SqliteDb.layer.pipe(Layer.provide(config))
+      const deps = Layer.merge(sqlite, config)
+      const live = OffsetStore.layer.pipe(Layer.provide(deps))
+      const program = Effect.flatMap(OffsetStore, (offsets) => run(offsets))
+      return yield* program.pipe(Effect.provide(live))
+    }),
+    "discord-offsets-",
+  )
+
+describe("OffsetStore", () => {
+  effectTest("stores and updates source offsets", () =>
+    withOffsets((offsets) =>
+      Effect.gen(function* () {
+        expect(Option.isNone(yield* offsets.getOffset("thread:t1"))).toBe(true)
+        yield* offsets.setOffset("thread:t1", "m1")
+        expect(yield* offsets.getOffset("thread:t1")).toEqual(Option.some("m1"))
+        yield* offsets.setOffset("thread:t1", "m9")
+        expect(yield* offsets.getOffset("thread:t1")).toEqual(Option.some("m9"))
+      }),
+    ),
+  )
+
+  effectTest("noop offsets always return none", () =>
+    Effect.gen(function* () {
+      const offsets = yield* OffsetStore
+      expect(Option.isNone(yield* offsets.getOffset("thread:t1"))).toBe(true)
+      yield* offsets.setOffset("thread:t1", "m1")
+      expect(Option.isNone(yield* offsets.getOffset("thread:t1"))).toBe(true)
+    }).pipe(Effect.provide(OffsetStore.noop)),
+  )
+})

+ 57 - 0
packages/discord/src/conversation/offsets.ts

@@ -0,0 +1,57 @@
+import * as Client from "@effect/sql/SqlClient"
+import { Context, Effect, Layer, Option } from "effect"
+import { SqliteDb } from "../db/client"
+import { initializeSchema } from "../db/init"
+import { DatabaseError } from "../errors"
+
+const db = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
+  effect.pipe(Effect.mapError((cause) => new DatabaseError({ cause })))
+
+export declare namespace OffsetStore {
+  export interface Service {
+    readonly getOffset: (source_id: string) => Effect.Effect<Option.Option<string>, DatabaseError>
+    readonly setOffset: (source_id: string, messageId: string) => Effect.Effect<void, DatabaseError>
+  }
+}
+
+export class OffsetStore extends Context.Tag("@discord/conversation/OffsetStore")<OffsetStore, OffsetStore.Service>() {
+  static readonly noop = Layer.succeed(
+    OffsetStore,
+    OffsetStore.of({
+      getOffset: () => Effect.succeed(Option.none()),
+      setOffset: () => Effect.void,
+    }),
+  )
+
+  static readonly layer = Layer.effect(
+    OffsetStore,
+    Effect.gen(function* () {
+      const sql = yield* SqliteDb
+      yield* db(initializeSchema.pipe(Effect.provideService(Client.SqlClient, sql)))
+
+      const getOffset = Effect.fn("OffsetStore.getOffset")(function* (source_id: string) {
+        const rows = yield* db(
+          sql<{ last_message_id: string }>`SELECT last_message_id
+              FROM conversation_offsets
+              WHERE source_id = ${source_id}
+              LIMIT 1`,
+        )
+        const row = rows[0]
+        if (!row) return Option.none<string>()
+        return Option.some(row.last_message_id)
+      })
+
+      const setOffset = Effect.fn("OffsetStore.setOffset")(function* (source_id: string, messageId: string) {
+        yield* db(
+          sql`INSERT INTO conversation_offsets (source_id, last_message_id, updated_at)
+              VALUES (${source_id}, ${messageId}, CURRENT_TIMESTAMP)
+              ON CONFLICT(source_id) DO UPDATE SET
+                last_message_id = excluded.last_message_id,
+                updated_at = CURRENT_TIMESTAMP`,
+        )
+      })
+
+      return OffsetStore.of({ getOffset, setOffset })
+    }),
+  )
+}

+ 5 - 5
packages/discord/src/conversation/services/outbox.ts → packages/discord/src/conversation/outbox.ts

@@ -1,12 +1,12 @@
 import { Context, Effect, Layer } from "effect"
-import type { ThreadId } from "../../types"
-import type { DeliveryError } from "../model/errors"
-import type { Action } from "../model/schema"
+import type { ThreadId } from "../types"
+import type { DeliveryError } from "./model/errors"
+import type { Action } from "./model/schema"
 
 export declare namespace Outbox {
   export interface Service {
     readonly publish: (action: Action) => Effect.Effect<void, DeliveryError>
-    readonly withTyping: <A, E, R>(thread_id: ThreadId, self: Effect.Effect<A, E, R>) => Effect.Effect<A, E | DeliveryError, R>
+    readonly withTyping: <A, E, R>(threadId: ThreadId, self: Effect.Effect<A, E, R>) => Effect.Effect<A, E | DeliveryError, R>
   }
 }
 
@@ -15,7 +15,7 @@ export class Outbox extends Context.Tag("@discord/conversation/Outbox")<Outbox,
     Outbox,
     Outbox.of({
       publish: () => Effect.void,
-      withTyping: (_thread_id, self) => self,
+      withTyping: (_threadId, self) => self,
     }),
   )
 }

+ 0 - 260
packages/discord/src/conversation/services/conversation.ts

@@ -1,260 +0,0 @@
-import { Context, Effect, Layer, Option, Schedule, Stream } from "effect"
-import { AppConfig } from "../../config"
-import { TurnRouter } from "../../discord/turn-routing"
-import { ActorMap } from "../../lib/actors/keyed"
-import { ThreadAgentPool } from "../../sandbox/pool"
-import type { ChannelId, ThreadId } from "../../types"
-import { type ConversationError, messageOf, RoutingError, SandboxSendError } from "../model/errors"
-import { Send, type Inbound } from "../model/schema"
-import { History } from "./history"
-import { Inbox } from "./inbox"
-import { ConversationLedger } from "./ledger"
-import { Outbox } from "./outbox"
-import { Threads } from "./threads"
-
-export declare namespace Conversation {
-  export interface Service {
-    readonly turn: (event: Inbound) => Effect.Effect<void, ConversationError>
-    readonly run: Effect.Effect<void>
-  }
-}
-
-export class Conversation extends Context.Tag("@discord/conversation/Conversation")<
-  Conversation,
-  Conversation.Service
->() {
-  static readonly layer = Layer.scoped(
-    Conversation,
-    Effect.gen(function* () {
-      const inbox = yield* Inbox
-      const outbox = yield* Outbox
-      const history = yield* History
-      const threads = yield* Threads
-      const ledger = yield* ConversationLedger
-      const config = yield* AppConfig
-      const pool = yield* ThreadAgentPool
-      const router = yield* TurnRouter
-      const actors = yield* ActorMap.make<string>()
-      const RETRIABLE_TAGS: ReadonlySet<string> = new Set([
-        "SandboxDeadError",
-        "OpenCodeClientError",
-        "HealthCheckError",
-        "SandboxStartError",
-      ])
-      const RETRY_MESSAGE = "Something went wrong. Please try again in a moment."
-      const turnRetry = Schedule.exponential("500 millis").pipe(
-        Schedule.intersect(Schedule.recurs(2)),
-        Schedule.whileInput((error: ConversationError) => error.retriable),
-      )
-
-      const asSendError =
-        (thread_id: ThreadId) =>
-        (cause: { readonly _tag: string }): SandboxSendError =>
-          SandboxSendError.make({
-            thread_id,
-            message: messageOf(cause),
-            retriable: RETRIABLE_TAGS.has(cause._tag),
-          })
-
-      const publishText = (threadId: ThreadId, text: string) =>
-        outbox.publish(
-          Send.make({
-            kind: "send",
-            thread_id: threadId,
-            text,
-          }),
-        )
-
-      const reportFailure = (thread_id: ThreadId) => (error: ConversationError) => {
-        if (error.retriable) return Effect.fail(error)
-        return publishText(thread_id, RETRY_MESSAGE).pipe(
-          Effect.catchAll(() => Effect.void),
-          Effect.zipRight(Effect.fail(error)),
-        )
-      }
-
-      const route = Effect.fn("Conversation.route")(function* (event: Inbound) {
-        if (event.author_is_bot) return false
-        if (event.mentions_everyone) return false
-        if (!event.content.trim()) return false
-
-        const mentioned =
-          event.mentions.user_ids.includes(event.bot_user_id) ||
-          (event.bot_role_id.length > 0 && event.mentions.role_ids.includes(event.bot_role_id))
-        if (event.kind === "channel_message") return mentioned
-        if (mentioned) return true
-
-        const owned = yield* pool.hasTrackedThread(event.thread_id).pipe(Effect.mapError(asSendError(event.thread_id)))
-        if (!owned) return false
-
-        const decision = yield* router
-          .shouldRespond({
-            content: event.content,
-            botUserId: event.bot_user_id,
-            botRoleId: event.bot_role_id,
-            mentionedUserIds: event.mentions.user_ids,
-            mentionedRoleIds: event.mentions.role_ids,
-          })
-          .pipe(
-            Effect.mapError((cause) =>
-              RoutingError.make({
-                message: messageOf(cause),
-                retriable: false,
-              }),
-            ),
-          )
-        return decision.shouldRespond
-      })
-
-      const resolve = Effect.fn("Conversation.resolve")(function* (event: Inbound) {
-        if (event.kind === "thread_message") {
-          return { thread_id: event.thread_id, channel_id: event.channel_id }
-        }
-        const name = yield* router.generateThreadName(event.content)
-        return yield* threads.ensure(event, name)
-      })
-
-      const buildInput = Effect.fn("Conversation.buildInput")(function* (
-        event: Inbound,
-        target: { thread_id: ThreadId; channel_id: ChannelId },
-      ) {
-        const toSendError = asSendError(target.thread_id)
-        const tracked = yield* pool.getTrackedSession(target.thread_id).pipe(Effect.mapError(toSendError))
-        const agent = yield* pool
-          .getOrCreate(target.thread_id, target.channel_id, event.guild_id)
-          .pipe(Effect.mapError(toSendError))
-        const current = yield* agent.current().pipe(Effect.mapError(toSendError))
-        const prompt =
-          Option.isSome(tracked) && tracked.value.sessionId !== current.sessionId
-            ? yield* history.rehydrate(target.thread_id, event.content)
-            : event.content
-        return { target, agent, prompt, session: current }
-      })
-
-      const command = (event: Inbound, target: { thread_id: ThreadId; channel_id: ChannelId }) =>
-        Effect.gen(function* () {
-          const text = event.content.trim().toLowerCase()
-          if (text === "!reset") {
-            yield* pool.destroySession(target.thread_id).pipe(Effect.catchAll(() => Effect.void))
-            yield* publishText(target.thread_id, "*☠️ Session destroyed. Next message will provision a fresh sandbox.*")
-            return true
-          }
-          if (text === "!status") {
-            const tracked = yield* pool
-              .getTrackedSession(target.thread_id)
-              .pipe(Effect.catchAll(() => Effect.succeed(Option.none())))
-            if (Option.isNone(tracked)) {
-              yield* publishText(target.thread_id, "*No active session for this thread.*")
-            } else {
-              const s = tracked.value
-              const model = config.openCodeModel.replace("opencode/", "")
-              const lines = [
-                `**Status:** ${s.status}`,
-                `**Model:** \`${model}\``,
-                `**Sandbox:** \`${s.sandboxId}\``,
-                `**Session:** \`${s.sessionId}\``,
-                s.resumeFailCount > 0 ? `**Resume failures:** ${s.resumeFailCount}` : null,
-                s.lastError ? `**Last error:** ${s.lastError.slice(0, 200)}` : null,
-              ].filter(Boolean)
-              yield* publishText(target.thread_id, lines.join("\n"))
-            }
-            return true
-          }
-          return false
-        })
-
-      const turnRaw = Effect.fn("Conversation.turnRaw")(function* (event: Inbound) {
-        if (!(yield* route(event))) return
-
-        const target = yield* resolve(event)
-
-        if (yield* command(event, target)) return
-
-        yield* Effect.logInfo("User message").pipe(
-          Effect.annotateLogs({
-            event: "conversation.user.message",
-            thread_id: target.thread_id,
-            author_id: event.author_id,
-            content: event.content.slice(0, 200),
-          }),
-        )
-
-        yield* outbox
-          .withTyping(
-            target.thread_id,
-            Effect.gen(function* () {
-              const input = yield* buildInput(event, target)
-
-              const reply = yield* input.agent.send(input.prompt).pipe(
-                Effect.catchTag("SandboxDeadError", () =>
-                  Effect.gen(function* () {
-                    yield* publishText(input.target.thread_id, "*Session changed state, recovering...*")
-                    const toErr = asSendError(input.target.thread_id)
-                    const next = yield* pool
-                      .getOrCreate(input.target.thread_id, input.target.channel_id, event.guild_id)
-                      .pipe(Effect.mapError(toErr))
-                    const nextSession = yield* next.current().pipe(Effect.mapError(toErr))
-                    const prompt =
-                      nextSession.sessionId !== input.session.sessionId
-                        ? yield* history.rehydrate(input.target.thread_id, event.content)
-                        : event.content
-                    return yield* next.send(prompt)
-                  }),
-                ),
-                Effect.mapError(asSendError(input.target.thread_id)),
-              )
-
-              yield* Effect.logInfo("Bot reply").pipe(
-                Effect.annotateLogs({
-                  event: "conversation.bot.reply",
-                  thread_id: input.target.thread_id,
-                  content: reply.slice(0, 200),
-                }),
-              )
-              yield* publishText(input.target.thread_id, reply)
-            }),
-          )
-          .pipe(Effect.catchAll(reportFailure(target.thread_id)))
-      })
-
-      const keyOf = (event: Inbound) =>
-        event.kind === "thread_message" ? `thread:${event.thread_id}` : `channel:${event.channel_id}`
-
-      const processEvent = (event: Inbound) => actors.run(keyOf(event), turnRaw(event), { touch: false })
-
-      const turn = Effect.fn("Conversation.turn")(function* (event: Inbound) {
-        const fresh = yield* ledger.dedup(event.message_id)
-        if (!fresh) return
-        yield* processEvent(event)
-      })
-
-      const run = inbox.events.pipe(
-        Stream.mapEffect(
-          (event) =>
-            ledger.dedup(event.message_id).pipe(
-              Effect.flatMap((fresh) => {
-                if (!fresh) return Effect.void
-                return processEvent(event).pipe(
-                  Effect.retry(turnRetry),
-                  Effect.catchAll((error) =>
-                    Effect.logError("Conversation turn failed").pipe(
-                      Effect.annotateLogs({
-                        event: "conversation.turn.failed",
-                        tag: error._tag,
-                        retriable: error.retriable,
-                        message: error.message,
-                      }),
-                    ),
-                  ),
-                )
-              }),
-            ),
-          { concurrency: "unbounded", unordered: true },
-        ),
-        Stream.runDrain,
-      )
-
-      return Conversation.of({ turn, run })
-    }),
-  )
-}

+ 0 - 93
packages/discord/src/conversation/services/ledger.test.ts

@@ -1,93 +0,0 @@
-import { describe, expect } from "bun:test"
-import { Duration, Effect, Layer, Option, Redacted } from "effect"
-import { AppConfig } from "../../config"
-import { SqliteDb } from "../../db/client"
-import { effectTest, withTempSqliteFile } from "../../test/effect"
-import { ConversationLedger } from "./ledger"
-
-const makeConfig = (databasePath: string) =>
-  AppConfig.of({
-    discordToken: Redacted.make("token"),
-    allowedChannelIds: [],
-    discordCategoryId: "",
-    discordRoleId: "",
-    discordRequiredRoleId: "",
-    discordCommandGuildId: "",
-    databasePath,
-    daytonaApiKey: Redacted.make("daytona"),
-    openCodeZenApiKey: Redacted.make("zen"),
-    githubToken: "",
-    logLevel: "info",
-    healthHost: "127.0.0.1",
-    healthPort: 8787,
-    turnRoutingMode: "off",
-    turnRoutingModel: "claude-haiku-4-5",
-    sandboxReusePolicy: "resume_preferred",
-    sandboxTimeout: Duration.minutes(30),
-    cleanupInterval: Duration.minutes(5),
-    staleActiveGraceMinutes: 5 as AppConfig.Service["staleActiveGraceMinutes"],
-    pausedTtlMinutes: 180 as AppConfig.Service["pausedTtlMinutes"],
-    activeHealthCheckTimeoutMs: 15000 as AppConfig.Service["activeHealthCheckTimeoutMs"],
-    startupHealthTimeoutMs: 120000 as AppConfig.Service["startupHealthTimeoutMs"],
-    resumeHealthTimeoutMs: 120000 as AppConfig.Service["resumeHealthTimeoutMs"],
-    sandboxCreationTimeout: 180 as AppConfig.Service["sandboxCreationTimeout"],
-    openCodeModel: "opencode/claude-sonnet-4-5",
-  })
-
-const withLedger = <A, E, R>(
-  run: (ledger: ConversationLedger.Service) => Effect.Effect<A, E, R>,
-) =>
-  withTempSqliteFile((databasePath) =>
-    Effect.gen(function* () {
-      const config = Layer.succeed(AppConfig, makeConfig(databasePath))
-      const sqlite = SqliteDb.layer.pipe(Layer.provide(config))
-      const deps = Layer.merge(sqlite, config)
-      const live = ConversationLedger.layer.pipe(Layer.provide(deps))
-      const program = Effect.flatMap(ConversationLedger, (ledger) => run(ledger))
-      return yield* program.pipe(Effect.provide(live))
-    }),
-    "discord-ledger-",
-  )
-
-describe("ConversationLedger", () => {
-  effectTest("dedup returns true first time, false second time", () =>
-    Effect.gen(function* () {
-      const ledger = yield* ConversationLedger
-      expect(yield* ledger.dedup("m1")).toBe(true)
-      expect(yield* ledger.dedup("m1")).toBe(false)
-      expect(yield* ledger.dedup("m2")).toBe(true)
-      expect(yield* ledger.dedup("m2")).toBe(false)
-    }).pipe(Effect.provide(ConversationLedger.noop)),
-  )
-
-  effectTest("stores and updates source offsets", () =>
-    withLedger((ledger) =>
-      Effect.gen(function* () {
-        expect(Option.isNone(yield* ledger.getOffset("thread:t1"))).toBe(true)
-        yield* ledger.setOffset("thread:t1", "m1")
-        expect(yield* ledger.getOffset("thread:t1")).toEqual(Option.some("m1"))
-        yield* ledger.setOffset("thread:t1", "m9")
-        expect(yield* ledger.getOffset("thread:t1")).toEqual(Option.some("m9"))
-      }),
-    ),
-  )
-
-  effectTest("dedup works in layer mode", () =>
-    withLedger((ledger) =>
-      Effect.gen(function* () {
-        expect(yield* ledger.dedup("m1")).toBe(true)
-        expect(yield* ledger.dedup("m1")).toBe(false)
-        expect(yield* ledger.dedup("m2")).toBe(true)
-      }),
-    ),
-  )
-
-  effectTest("noop offsets always return none", () =>
-    Effect.gen(function* () {
-      const ledger = yield* ConversationLedger
-      expect(Option.isNone(yield* ledger.getOffset("thread:t1"))).toBe(true)
-      yield* ledger.setOffset("thread:t1", "m1")
-      expect(Option.isNone(yield* ledger.getOffset("thread:t1"))).toBe(true)
-    }).pipe(Effect.provide(ConversationLedger.noop)),
-  )
-})

+ 0 - 85
packages/discord/src/conversation/services/ledger.ts

@@ -1,85 +0,0 @@
-import * as Client from "@effect/sql/SqlClient"
-import { Context, Effect, Layer, Option } from "effect"
-import { SqliteDb } from "../../db/client"
-import { initializeSchema } from "../../db/init"
-import { DatabaseError } from "../../errors"
-
-const DEDUP_LIMIT = 4_000
-
-const db = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
-  effect.pipe(Effect.mapError((cause) => new DatabaseError({ cause })))
-
-const makeDedupSet = () => {
-  const seen = new Set<string>()
-  const order: Array<string> = []
-  return (message_id: string): boolean => {
-    if (seen.has(message_id)) return false
-    seen.add(message_id)
-    order.push(message_id)
-    if (order.length > DEDUP_LIMIT) {
-      const oldest = order.shift()
-      if (oldest) seen.delete(oldest)
-    }
-    return true
-  }
-}
-
-export declare namespace ConversationLedger {
-  export interface Service {
-    readonly dedup: (message_id: string) => Effect.Effect<boolean>
-    readonly getOffset: (source_id: string) => Effect.Effect<Option.Option<string>, DatabaseError>
-    readonly setOffset: (source_id: string, message_id: string) => Effect.Effect<void, DatabaseError>
-  }
-}
-
-export class ConversationLedger extends Context.Tag("@discord/conversation/ConversationLedger")<
-  ConversationLedger,
-  ConversationLedger.Service
->() {
-  static readonly noop = Layer.sync(ConversationLedger, () => {
-    const check = makeDedupSet()
-    return ConversationLedger.of({
-      dedup: (message_id) => Effect.sync(() => check(message_id)),
-      getOffset: () => Effect.succeed(Option.none()),
-      setOffset: () => Effect.void,
-    })
-  })
-
-  static readonly layer = Layer.effect(
-    ConversationLedger,
-    Effect.gen(function* () {
-      const sql = yield* SqliteDb
-      yield* db(initializeSchema.pipe(Effect.provideService(Client.SqlClient, sql)))
-
-      const check = makeDedupSet()
-
-      const getOffset = Effect.fn("ConversationLedger.getOffset")(function* (source_id: string) {
-        const rows = yield* db(
-          sql<{ last_message_id: string }>`SELECT last_message_id
-              FROM conversation_offsets
-              WHERE source_id = ${source_id}
-              LIMIT 1`,
-        )
-        const row = rows[0]
-        if (!row) return Option.none<string>()
-        return Option.some(row.last_message_id)
-      })
-
-      const setOffset = Effect.fn("ConversationLedger.setOffset")(function* (source_id: string, message_id: string) {
-        yield* db(
-          sql`INSERT INTO conversation_offsets (source_id, last_message_id, updated_at)
-              VALUES (${source_id}, ${message_id}, CURRENT_TIMESTAMP)
-              ON CONFLICT(source_id) DO UPDATE SET
-                last_message_id = excluded.last_message_id,
-                updated_at = CURRENT_TIMESTAMP`,
-        )
-      })
-
-      return ConversationLedger.of({
-        dedup: (message_id) => Effect.sync(() => check(message_id)),
-        getOffset,
-        setOffset,
-      })
-    }),
-  )
-}

+ 61 - 0
packages/discord/src/conversation/thread/cluster/chat-live.ts

@@ -0,0 +1,61 @@
+import { Effect, Layer, Option } from "effect"
+import { DatabaseError } from "../../../errors"
+import type { ThreadId } from "../../../types"
+import { SendInput, ThreadChatCluster, ThreadChatError } from "./contracts"
+import { ThreadEntity } from "./entity"
+
+const asThreadError = (threadId: ThreadId, cause: unknown): ThreadChatError => {
+  if (cause instanceof ThreadChatError) return cause
+  return ThreadChatError.make({
+    threadId,
+    cause,
+    retriable: false,
+  })
+}
+
+const asDatabaseError = (cause: unknown): DatabaseError => {
+  if (cause instanceof DatabaseError) return cause
+  return DatabaseError.make({ cause })
+}
+
+export const ThreadChatClusterLive = Layer.effect(
+  ThreadChatCluster,
+  Effect.gen(function* () {
+    const make = yield* ThreadEntity.client
+
+    const send = Effect.fn("ThreadChatCluster.send")(function* (input) {
+      const rpc = make(input.threadId)
+      const out = yield* rpc
+        .send(
+          SendInput.make({
+            channelId: input.channelId,
+            guildId: input.guildId,
+            messageId: input.messageId,
+            text: input.text,
+          }),
+        )
+        .pipe(
+        Effect.mapError((cause) => asThreadError(input.threadId, cause)),
+      )
+      return {
+        text: out.text,
+        session: out.session,
+        changedSession: out.changedSession,
+      }
+    })
+
+    const status = Effect.fn("ThreadChatCluster.status")(function* (threadId) {
+      const rpc = make(threadId)
+      const row = yield* rpc.status(undefined).pipe(Effect.mapError(asDatabaseError))
+      if (row === null) return Option.none()
+      return Option.some(row)
+    })
+
+    const recreate = Effect.fn("ThreadChatCluster.recreate")(function* (threadId) {
+      const rpc = make(threadId)
+      yield* rpc.recreate(undefined).pipe(Effect.mapError(asDatabaseError))
+    })
+
+    return ThreadChatCluster.of({ send, status, recreate })
+  }),
+)

+ 97 - 0
packages/discord/src/conversation/thread/cluster/contracts.ts

@@ -0,0 +1,97 @@
+import { Context, Effect, Option, PrimaryKey, Schema } from "effect"
+import { DatabaseError } from "../../../errors"
+import { ChannelId, GuildId, SandboxId, SessionInfo, ThreadId } from "../../../types"
+
+export class ThreadChatError extends Schema.TaggedError<ThreadChatError>()(
+  "ThreadChatError",
+  {
+    threadId: ThreadId,
+    cause: Schema.Defect,
+    retriable: Schema.Boolean,
+  },
+) {}
+
+export class SendInput extends Schema.Class<SendInput>("ClusterMode/SendInput")({
+  channelId: ChannelId,
+  guildId: GuildId,
+  messageId: Schema.String,
+  text: Schema.String,
+}) {
+  [PrimaryKey.symbol]() {
+    return this.messageId
+  }
+}
+
+export class SendOutput extends Schema.Class<SendOutput>("ClusterMode/SendOutput")({
+  text: Schema.String,
+  session: SessionInfo,
+  changedSession: Schema.Boolean,
+}) {}
+
+export class PauseInput extends Schema.Class<PauseInput>("ClusterMode/PauseInput")({
+  reason: Schema.String,
+}) {}
+
+export class ResumeInput extends Schema.Class<ResumeInput>("ClusterMode/ResumeInput")({
+  channelId: Schema.NullOr(ChannelId),
+  guildId: Schema.NullOr(GuildId),
+}) {}
+
+export class LogsInput extends Schema.Class<LogsInput>("ClusterMode/LogsInput")({
+  lines: Schema.Number.pipe(
+    Schema.int(),
+    Schema.between(1, 500),
+  ),
+}) {}
+
+export class LogsOutput extends Schema.Class<LogsOutput>("ClusterMode/LogsOutput")({
+  sandboxId: SandboxId,
+  output: Schema.String,
+}) {}
+
+export declare namespace ThreadChatCluster {
+  export interface Service {
+    readonly send: (input: {
+      threadId: ThreadId
+      channelId: ChannelId
+      guildId: GuildId
+      messageId: string
+      text: string
+    }) => Effect.Effect<{
+      text: string
+      session: SessionInfo
+      changedSession: boolean
+    }, ThreadChatError>
+    readonly status: (threadId: ThreadId) => Effect.Effect<Option.Option<SessionInfo>, DatabaseError>
+    readonly recreate: (threadId: ThreadId) => Effect.Effect<void, DatabaseError>
+  }
+}
+
+export class ThreadChatCluster extends Context.Tag("@discord/conversation/thread/cluster/ThreadChatCluster")<
+  ThreadChatCluster,
+  ThreadChatCluster.Service
+>() {}
+
+export declare namespace ThreadControlCluster {
+  export interface Service {
+    readonly active: Effect.Effect<ReadonlyArray<SessionInfo>, DatabaseError>
+    readonly pause: (input: {
+      threadId: ThreadId
+      reason: string
+    }) => Effect.Effect<Option.Option<SessionInfo>, ThreadChatError>
+    readonly resume: (input: {
+      threadId: ThreadId
+      channelId: ChannelId | null
+      guildId: GuildId | null
+    }) => Effect.Effect<SessionInfo, ThreadChatError>
+    readonly logs: (input: {
+      threadId: ThreadId
+      lines: number
+    }) => Effect.Effect<Option.Option<{ sandboxId: SandboxId; output: string }>, ThreadChatError>
+  }
+}
+
+export class ThreadControlCluster extends Context.Tag("@discord/conversation/thread/cluster/ThreadControlCluster")<
+  ThreadControlCluster,
+  ThreadControlCluster.Service
+>() {}

+ 65 - 0
packages/discord/src/conversation/thread/cluster/control-live.ts

@@ -0,0 +1,65 @@
+import { Effect, Layer, Option } from "effect"
+import { DatabaseError } from "../../../errors"
+import { SessionStore } from "../../../session/store"
+import type { ThreadId } from "../../../types"
+import { LogsInput, PauseInput, ResumeInput, ThreadChatError, ThreadControlCluster } from "./contracts"
+import { ThreadEntity } from "./entity"
+
+const asThreadError = (threadId: ThreadId, cause: unknown): ThreadChatError => {
+  if (cause instanceof ThreadChatError) return cause
+  return ThreadChatError.make({
+    threadId,
+    cause,
+    retriable: false,
+  })
+}
+
+const asDatabaseError = (cause: unknown): DatabaseError => {
+  if (cause instanceof DatabaseError) return cause
+  return DatabaseError.make({ cause })
+}
+
+export const ThreadControlClusterLive = Layer.effect(
+  ThreadControlCluster,
+  Effect.gen(function* () {
+    const make = yield* ThreadEntity.client
+    const store = yield* SessionStore
+
+    const active = store.listActive().pipe(Effect.mapError(asDatabaseError))
+
+    const pause = Effect.fn("ThreadControlCluster.pause")(function* (input) {
+      const rpc = make(input.threadId)
+      const row = yield* rpc.pause(PauseInput.make({ reason: input.reason })).pipe(
+        Effect.mapError((cause) => asThreadError(input.threadId, cause)),
+      )
+      if (row === null) return Option.none()
+      return Option.some(row)
+    })
+
+    const resume = Effect.fn("ThreadControlCluster.resume")(function* (input) {
+      const rpc = make(input.threadId)
+      return yield* rpc.resume(
+        ResumeInput.make({
+          channelId: input.channelId,
+          guildId: input.guildId,
+        }),
+      ).pipe(
+        Effect.mapError((cause) => asThreadError(input.threadId, cause)),
+      )
+    })
+
+    const logs = Effect.fn("ThreadControlCluster.logs")(function* (input) {
+      const rpc = make(input.threadId)
+      const row = yield* rpc.logs(LogsInput.make({ lines: input.lines })).pipe(
+        Effect.mapError((cause) => asThreadError(input.threadId, cause)),
+      )
+      if (row === null) return Option.none()
+      return Option.some({
+        sandboxId: row.sandboxId,
+        output: row.output,
+      })
+    })
+
+    return ThreadControlCluster.of({ active, pause, resume, logs })
+  }),
+)

+ 150 - 0
packages/discord/src/conversation/thread/cluster/entity.test.ts

@@ -0,0 +1,150 @@
+import { describe, expect } from "bun:test"
+import { TestRunner } from "@effect/cluster"
+import { Effect, Layer, Option } from "effect"
+import { History } from "../../history"
+import { ThreadChatCluster, ThreadChatClusterLive, ThreadEntityLive } from "."
+import { OpenCodeClient, OpenCodeSessionSummary } from "../../../sandbox/opencode/client"
+import { SandboxProvisioner } from "../../../sandbox/provisioner"
+import { SessionStore } from "../../../session/store"
+import { ChannelId, GuildId, PreviewAccess, SandboxId, SessionId, SessionInfo, ThreadId } from "../../../types"
+import { DaytonaService } from "../../../sandbox/daytona/service"
+import { OpenCodeClientError } from "../../../errors"
+import { SqliteDb } from "../../../db/client"
+import { effectTest, testConfigLayer } from "../../../test/effect"
+
+const makeSession = (input: { threadId: ThreadId; channelId: ChannelId; guildId: GuildId; sandboxId: string; sessionId: string }) =>
+  SessionInfo.make({
+    threadId: input.threadId,
+    channelId: input.channelId,
+    guildId: input.guildId,
+    sandboxId: SandboxId.make(input.sandboxId),
+    sessionId: SessionId.make(input.sessionId),
+    previewUrl: "https://preview",
+    previewToken: null,
+    status: "active",
+    lastError: null,
+    resumeFailCount: 0,
+  })
+
+describe("ThreadEntity", () => {
+  effectTest("retries after sandbox-down by re-ensuring session and rehydrating prompt", () => {
+    const threadId = ThreadId.make("t-dead")
+    const channelId = ChannelId.make("c-dead")
+    const guildId = GuildId.make("g-dead")
+    const first = makeSession({ threadId, channelId, guildId, sandboxId: "sb-1", sessionId: "s-1" })
+    const second = makeSession({ threadId, channelId, guildId, sandboxId: "sb-2", sessionId: "s-2" })
+
+    const ensured: Array<string> = []
+    const recovered: Array<string> = []
+    const sent: Array<string> = []
+    const rehydrated: Array<string> = []
+
+    const historyLayer = Layer.succeed(
+      History,
+      History.of({
+        rehydrate: (_threadId, latest) =>
+          Effect.sync(() => {
+            rehydrated.push(latest)
+            return `rehydrated:${latest}`
+          }),
+      }),
+    )
+
+    const provisionerLayer = Layer.succeed(
+      SandboxProvisioner,
+      SandboxProvisioner.of({
+        provision: () => Effect.dieMessage("unused"),
+        resume: () => Effect.dieMessage("unused"),
+        ensureActive: (input) =>
+          Effect.sync(() => {
+            ensured.push(Option.isSome(input.current) ? String(input.current.value.sessionId) : "none")
+            return ensured.length === 1 ? first : second
+          }),
+        ensureHealthy: () => Effect.succeed(true),
+        recoverSendFailure: (_threadId, session) =>
+          Effect.sync(() => {
+            recovered.push(String(session.sessionId))
+            return SessionInfo.make({
+              ...session,
+              status: "error",
+              lastError: "send-failed",
+            })
+          }),
+        pause: (_threadId, session) => Effect.succeed(session),
+        destroy: (_threadId, session) => Effect.succeed(session),
+      }),
+    )
+
+    const openCodeLayer = Layer.succeed(
+      OpenCodeClient,
+      OpenCodeClient.of({
+        waitForHealthy: () => Effect.succeed(true),
+        createSession: () => Effect.succeed(SessionId.make("unused")),
+        sessionExists: () => Effect.succeed(true),
+        listSessions: () => Effect.succeed([] as ReadonlyArray<OpenCodeSessionSummary>),
+        sendPrompt: (_preview, _sessionId, text) =>
+          Effect.gen(function* () {
+            sent.push(text)
+            if (sent.length === 1) {
+              return yield* OpenCodeClientError.make({
+                operation: "sendPrompt",
+                statusCode: 502,
+                body: "bad gateway",
+                kind: "sandbox-down",
+              })
+            }
+            return `ok:${text}`
+          }),
+        abortSession: () => Effect.void,
+      }),
+    )
+
+    const daytonaLayer = Layer.succeed(
+      DaytonaService,
+      DaytonaService.of({
+        create: () => Effect.dieMessage("unused"),
+        exec: () => Effect.dieMessage("unused"),
+        start: () => Effect.dieMessage("unused"),
+        stop: () => Effect.dieMessage("unused"),
+        destroy: () => Effect.void,
+        getPreview: () =>
+          Effect.succeed(
+            PreviewAccess.make({
+              previewUrl: "https://preview",
+              previewToken: null,
+            }),
+          ),
+      }),
+    )
+
+    const live = ThreadChatClusterLive.pipe(
+      Layer.provideMerge(ThreadEntityLive),
+      Layer.provideMerge(TestRunner.layer),
+      Layer.provideMerge(SessionStore.layer),
+      Layer.provideMerge(SqliteDb.layer),
+      Layer.provideMerge(testConfigLayer),
+      Layer.provideMerge(daytonaLayer),
+      Layer.provideMerge(openCodeLayer),
+      Layer.provideMerge(provisionerLayer),
+      Layer.provideMerge(historyLayer),
+    )
+
+    return Effect.gen(function* () {
+      const threadChat = yield* ThreadChatCluster
+      const out = yield* threadChat.send({
+        threadId,
+        channelId,
+        guildId,
+        messageId: "m-1",
+        text: "hello",
+      })
+
+      expect(ensured).toEqual(["none", "s-1"])
+      expect(recovered).toEqual(["s-1"])
+      expect(rehydrated).toEqual(["hello"])
+      expect(sent).toEqual(["hello", "rehydrated:hello"])
+      expect(out.text).toBe("ok:rehydrated:hello")
+      expect(out.session.sessionId).toBe(SessionId.make("s-2"))
+    }).pipe(Effect.provide(live))
+  })
+})

+ 315 - 0
packages/discord/src/conversation/thread/cluster/entity.ts

@@ -0,0 +1,315 @@
+import { ClusterSchema, Entity } from "@effect/cluster"
+import { Rpc } from "@effect/rpc"
+import { Effect, Option, Predicate, Schema } from "effect"
+import {
+  DatabaseError,
+  type HealthCheckError,
+  isOpenCodeSandboxUnavailable,
+  OpenCodeClientError,
+  SandboxDeadError,
+  type SandboxStartError,
+} from "../../../errors"
+import { StatefulActor } from "../../../lib/actors/stateful"
+import { DaytonaService } from "../../../sandbox/daytona/service"
+import { OpenCodeClient } from "../../../sandbox/opencode/client"
+import { SandboxProvisioner } from "../../../sandbox/provisioner"
+import { SessionStore } from "../../../session/store"
+import { ChannelId, GuildId, PreviewAccess, SessionInfo, ThreadId } from "../../../types"
+import { History } from "../../history"
+import {
+  LogsInput,
+  LogsOutput,
+  PauseInput,
+  ResumeInput,
+  SendInput,
+  SendOutput,
+  ThreadChatError,
+} from "./contracts"
+
+const StatusInput = Schema.Void
+const RecreateInput = Schema.Void
+
+type RetriableCause = SandboxDeadError | OpenCodeClientError | HealthCheckError | SandboxStartError
+
+const isRetriableCause = (cause: unknown): cause is RetriableCause =>
+  Predicate.isTagged(cause, "SandboxDeadError") ||
+  Predicate.isTagged(cause, "OpenCodeClientError") ||
+  Predicate.isTagged(cause, "HealthCheckError") ||
+  Predicate.isTagged(cause, "SandboxStartError")
+
+const toThreadError = (threadId: ThreadId, cause: unknown): ThreadChatError =>
+  ThreadChatError.make({
+    threadId,
+    cause,
+    retriable: isRetriableCause(cause),
+  })
+
+class ThreadState extends Schema.Class<ThreadState>("ClusterMode/ThreadState")({
+  loaded: Schema.Boolean,
+  session: Schema.NullOr(SessionInfo),
+}) {
+  static empty() {
+    return ThreadState.make({ loaded: false, session: null })
+  }
+
+  option() {
+    return Option.fromNullable(this.session)
+  }
+
+  hydrate(row: Option.Option<SessionInfo>) {
+    if (Option.isNone(row)) return ThreadState.make({ loaded: true, session: null })
+    return ThreadState.make({ loaded: true, session: row.value })
+  }
+
+  with(session: SessionInfo) {
+    return ThreadState.make({ loaded: true, session })
+  }
+
+  clear() {
+    return ThreadState.make({ loaded: true, session: null })
+  }
+}
+
+const SendRpc = Rpc.make("send", {
+  payload: SendInput,
+  success: SendOutput,
+  error: ThreadChatError,
+}).annotate(ClusterSchema.Persisted, true)
+
+const StatusRpc = Rpc.make("status", {
+  payload: StatusInput,
+  success: Schema.NullOr(SessionInfo),
+  error: DatabaseError,
+})
+
+const RecreateRpc = Rpc.make("recreate", {
+  payload: RecreateInput,
+  success: Schema.Void,
+  error: DatabaseError,
+})
+
+const PauseRpc = Rpc.make("pause", {
+  payload: PauseInput,
+  success: Schema.NullOr(SessionInfo),
+  error: ThreadChatError,
+})
+
+const ResumeRpc = Rpc.make("resume", {
+  payload: ResumeInput,
+  success: SessionInfo,
+  error: ThreadChatError,
+})
+
+const LogsRpc = Rpc.make("logs", {
+  payload: LogsInput,
+  success: Schema.NullOr(LogsOutput),
+  error: ThreadChatError,
+})
+
+export const ThreadEntity = Entity.make("ThreadChat", [
+  SendRpc,
+  StatusRpc,
+  RecreateRpc,
+  PauseRpc,
+  ResumeRpc,
+  LogsRpc,
+])
+
+export const ThreadEntityLive = ThreadEntity.toLayer(
+  Effect.gen(function* () {
+    const oc = yield* OpenCodeClient
+    const daytona = yield* DaytonaService
+    const store = yield* SessionStore
+    const history = yield* History
+    const provisioner = yield* SandboxProvisioner
+    const entityId = String((yield* Entity.CurrentAddress).entityId)
+    if (entityId.includes("/")) {
+      return yield* Effect.dieMessage(`ThreadEntity expected raw threadId entityId, got "${entityId}"`)
+    }
+    const threadId = ThreadId.make(entityId)
+    const thread = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
+      effect.pipe(Effect.mapError((cause) => toThreadError(threadId, cause)))
+
+    const state = StatefulActor.make(ThreadState.empty())
+    const stateNow = () => state.get().option()
+    const setState = (session: SessionInfo) => {
+      state.update((current) => current.with(session))
+    }
+    const clearState = () => {
+      state.update((current) => current.clear())
+    }
+
+    /** Bootstrap state from DB once, then serve from in-memory state. */
+    const load = Effect.fn("ThreadEntity.load")(function* () {
+      const current = state.get()
+      if (current.loaded) return current.option()
+      const row = yield* store.getByThread(threadId)
+      const hydrated = current.hydrate(row)
+      state.set(hydrated)
+      return hydrated.option()
+    })
+
+    /** Persist the latest session snapshot for crash recovery. */
+    const saveSession = Effect.fnUntraced(function* (session: SessionInfo) {
+      yield* store.upsert(session)
+    })
+
+    /** Update in-memory state and persist it as one operation. */
+    const commitSession = Effect.fnUntraced(function* (session: SessionInfo) {
+      setState(session)
+      yield* saveSession(session)
+    })
+
+    /** Ensure there is an active session for this thread (resume or create). */
+    const ensureSession = Effect.fnUntraced(function* (
+      channelId: ChannelId,
+      guildId: GuildId,
+      current: Option.Option<SessionInfo>,
+    ) {
+      return yield* provisioner.ensureActive({
+        threadId,
+        channelId,
+        guildId,
+        current,
+      })
+    })
+
+    /** Rebuild prompt context when session changed; otherwise keep prompt as-is. */
+    const rehydrate = (latest: string, before: Option.Option<SessionInfo>, next: SessionInfo) => {
+      if (Option.isNone(before)) return Effect.succeed(latest)
+      if (before.value.sessionId === next.sessionId) return Effect.succeed(latest)
+      return history.rehydrate(threadId, latest)
+    }
+
+    const recoverFailure = (session: SessionInfo, error: OpenCodeClientError) =>
+      isOpenCodeSandboxUnavailable(error)
+        ? SandboxDeadError.make({
+          threadId: session.threadId,
+          reason: `OpenCode send failed (${error.statusCode})`,
+        })
+        : error
+
+    const recoverSendError = Effect.fnUntraced(function* (session: SessionInfo, error: OpenCodeClientError) {
+      const next = yield* provisioner.recoverSendFailure(session.threadId, session, error)
+      yield* commitSession(next)
+      return yield* recoverFailure(session, error)
+    })
+
+    /** Send prompt to OpenCode and record recovery state if send failed. */
+    const sendPrompt = Effect.fnUntraced(function* (session: SessionInfo, text: string) {
+      yield* store.markActivity(session.threadId)
+      return yield* oc
+        .sendPrompt(PreviewAccess.from(session), session.sessionId, text)
+        .pipe(Effect.catchTag("OpenCodeClientError", (error) => recoverSendError(session, error)))
+    })
+
+    /** Retry exactly once after sandbox-dead by re-ensuring and rehydrating. */
+    const retrySend = Effect.fnUntraced(function* (payload: SendInput, prior: SessionInfo, text: string) {
+      return yield* sendPrompt(prior, text).pipe(
+        Effect.catchTag("SandboxDeadError", () =>
+          Effect.gen(function* () {
+            const resumed = yield* ensureSession(payload.channelId, payload.guildId, stateNow())
+            yield* commitSession(resumed)
+            const next = yield* rehydrate(payload.text, Option.some(prior), resumed)
+            return yield* sendPrompt(resumed, next)
+          }),
+        ),
+      )
+    })
+
+    /** Full send lifecycle: load, ensure, persist, rehydrate, send, retry, return output. */
+    const sendNow = Effect.fnUntraced(function* (payload: SendInput) {
+      return yield* Effect.gen(function* () {
+        const before = yield* load()
+        const active = yield* ensureSession(payload.channelId, payload.guildId, before)
+        yield* commitSession(active)
+        const first = yield* rehydrate(payload.text, before, active)
+        const text = yield* retrySend(payload, active, first)
+        const session = Option.getOrElse(stateNow(), () => active)
+        return SendOutput.make({
+          text,
+          session,
+          changedSession: Option.isSome(before) ? before.value.sessionId !== session.sessionId : false,
+        })
+      })
+    })
+
+    /** Full send lifecycle: load, ensure, persist, rehydrate, send, retry, return output. */
+    const send = Effect.fn("ThreadEntity.send")(function* ({ payload }) {
+      return yield* thread(sendNow(payload))
+    })
+
+    /** Read current session from actor state (bootstrapping from DB on first access). */
+    const status = Effect.fn("ThreadEntity.status")(function* () {
+      const row = yield* load()
+      if (Option.isNone(row)) return null
+      return row.value
+    })
+
+    /** Destroy current session resources for this thread and clear actor state. */
+    const recreate = Effect.fn("ThreadEntity.recreate")(function* () {
+      const row = yield* load()
+      if (Option.isNone(row)) {
+        clearState()
+        return
+      }
+      const next = yield* provisioner.destroy(threadId, row.value, "cluster-recreate")
+      yield* store.upsert(next)
+      clearState()
+    })
+
+    /** Pause active session if present and commit paused state. */
+    const pause = Effect.fn("ThreadEntity.pause")(function* ({ payload }) {
+      return yield* thread(Effect.gen(function* () {
+        const row = yield* load()
+        if (Option.isNone(row)) return null
+        const next = yield* provisioner.pause(threadId, row.value, payload.reason)
+        yield* commitSession(next)
+        return next
+      }))
+    })
+
+    /** Resume existing session or create one when missing, then commit state. */
+    const resume = Effect.fn("ThreadEntity.resume")(function* ({ payload }) {
+      return yield* thread(Effect.gen(function* () {
+        const before = yield* load()
+        const channelId = payload.channelId === null
+          ? Option.match(before, {
+            onNone: () => ChannelId.make("ctl"),
+            onSome: (row) => row.channelId,
+          })
+          : payload.channelId
+        const guildId = payload.guildId === null
+          ? Option.match(before, {
+            onNone: () => GuildId.make("local"),
+            onSome: (row) => row.guildId,
+          })
+          : payload.guildId
+        const next = yield* ensureSession(channelId, guildId, before)
+        yield* commitSession(next)
+        return next
+      }))
+    })
+
+    /** Read sandbox log tail for active session if present. */
+    const logs = Effect.fn("ThreadEntity.logs")(function* ({ payload }) {
+      return yield* thread(Effect.gen(function* () {
+        const row = yield* load()
+        if (Option.isNone(row)) return null
+        const out = yield* daytona.exec(
+          row.value.sandboxId,
+          "read-opencode-log",
+          `cat /tmp/opencode.log 2>/dev/null | tail -${payload.lines}`,
+        )
+        return LogsOutput.make({
+          sandboxId: row.value.sandboxId,
+          output: out.output,
+        })
+      }))
+    })
+
+    const handlers = { send, status, recreate, pause, resume, logs }
+    return ThreadEntity.of(handlers)
+  }),
+  { maxIdleTime: "30 minutes" },
+)

+ 14 - 0
packages/discord/src/conversation/thread/cluster/index.ts

@@ -0,0 +1,14 @@
+export {
+  LogsInput,
+  LogsOutput,
+  PauseInput,
+  ResumeInput,
+  SendInput,
+  SendOutput,
+  ThreadChatCluster,
+  ThreadControlCluster,
+  ThreadChatError,
+} from "./contracts"
+export { ThreadEntity, ThreadEntityLive } from "./entity"
+export { ThreadChatClusterLive } from "./chat-live"
+export { ThreadControlClusterLive } from "./control-live"

+ 4 - 4
packages/discord/src/conversation/services/threads.ts → packages/discord/src/conversation/threads.ts

@@ -1,6 +1,6 @@
 import { Context, Effect, Layer } from "effect"
-import { ThreadEnsureError } from "../model/errors"
-import type { Inbound, ThreadRef } from "../model/schema"
+import { ThreadEnsureError } from "./model/errors"
+import type { Inbound, ThreadRef } from "./model/schema"
 
 export declare namespace Threads {
   export interface Service {
@@ -14,11 +14,11 @@ export class Threads extends Context.Tag("@discord/conversation/Threads")<Thread
     Threads.of({
       ensure: (event) => {
         if (event.kind === "thread_message") {
-          return Effect.succeed({ thread_id: event.thread_id, channel_id: event.channel_id })
+          return Effect.succeed({ threadId: event.threadId, channelId: event.channelId })
         }
         return Effect.fail(
           ThreadEnsureError.make({
-            channel_id: event.channel_id,
+            channelId: event.channelId,
             message: "threads adapter missing for channel message",
             retriable: false,
           }),

+ 11 - 12
packages/discord/src/db/client.ts

@@ -1,22 +1,21 @@
 import { SqliteClient } from "@effect/sql-sqlite-bun"
+import { Reactivity } from "@effect/experimental"
 import * as Client from "@effect/sql/SqlClient"
 import { Context, Effect, Layer } from "effect"
 import { AppConfig } from "../config"
 
 export class SqliteDb extends Context.Tag("@discord/SqliteDb")<SqliteDb, Client.SqlClient>() {
-  static readonly layer = Layer.effect(
-    SqliteDb,
+  static readonly layer = Layer.scopedContext(
     Effect.gen(function* () {
-      const db = yield* Client.SqlClient
+      const config = yield* AppConfig
+      const db = yield* SqliteClient.make({ filename: config.databasePath }).pipe(
+        Effect.provide(Reactivity.layer),
+      )
       yield* db`PRAGMA busy_timeout = 5000`
-      return db
+      return Context.empty().pipe(
+        Context.add(SqliteDb, db),
+        Context.add(Client.SqlClient, db),
+      )
     }),
-  ).pipe(
-    Layer.provide(
-      Layer.unwrapEffect(
-        Effect.map(AppConfig, (config) => SqliteClient.layer({ filename: config.databasePath })),
-      ),
-    ),
-    Layer.orDie,
-  )
+  ).pipe(Layer.orDie)
 }

+ 0 - 2
packages/discord/src/db/init.ts

@@ -4,12 +4,10 @@ import * as Migrator from "@effect/sql/Migrator"
 import * as Client from "@effect/sql/SqlClient"
 import { Effect } from "effect"
 import migration0001 from "./migrations/0001_discord_sessions"
-import migration0002 from "./migrations/0002_conversation_offsets"
 
 const run = Migrator.make({})({
   loader: Migrator.fromRecord({
     "0001_discord_sessions": migration0001,
-    "0002_conversation_offsets": migration0002,
   }),
 })
 

+ 0 - 31
packages/discord/src/db/migrations/0002_conversation_offsets.ts

@@ -1,31 +0,0 @@
-import * as Client from "@effect/sql/SqlClient"
-import { Effect } from "effect"
-
-const TABLE = `CREATE TABLE IF NOT EXISTS conversation_offsets (
-  source_id TEXT PRIMARY KEY,
-  last_message_id TEXT NOT NULL,
-  updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
-)`
-
-const COLUMNS = [
-  ["last_message_id", "TEXT NOT NULL"],
-  ["updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP"],
-] as const
-
-const INDEXES = [
-  `CREATE INDEX IF NOT EXISTS conversation_offsets_updated_at_idx
-    ON conversation_offsets (updated_at)`,
-] as const
-
-export default Effect.gen(function* () {
-  const db = yield* Client.SqlClient
-  yield* db.unsafe(TABLE)
-
-  const names = new Set((yield* db<{ name: string }>`PRAGMA table_info(conversation_offsets)`).map((row) => row.name))
-  const missing = COLUMNS.filter(([name]) => !names.has(name))
-  yield* Effect.forEach(missing, ([name, definition]) => db.unsafe(`ALTER TABLE conversation_offsets ADD COLUMN ${name} ${definition}`), {
-    discard: true,
-  })
-
-  yield* Effect.forEach(INDEXES, (index) => db.unsafe(index), { discard: true })
-})

+ 177 - 335
packages/discord/src/conversation/implementations/discord/index.ts → packages/discord/src/discord/adapter.ts

@@ -1,222 +1,81 @@
-import type { ChatInputCommandInteraction, GuildMember, Interaction, Message, TextChannel, ThreadChannel } from "discord.js"
+import type { ChatInputCommandInteraction, GuildMember, Interaction, Message, TextChannel } from "discord.js"
 import { ChannelType, MessageFlags } from "discord.js"
 import { Context, Effect, Layer, Option, Queue, Ref, Runtime, Schedule, Stream } from "effect"
-import { AppConfig } from "../../../config"
-import { DiscordClient } from "../../../discord/client"
-import { TYPING_INTERVAL } from "../../../discord/constants"
-import { cleanResponse, splitForDiscord } from "../../../discord/format"
-import { ThreadAgentPool } from "../../../sandbox/pool"
-import { SessionStore } from "../../../sessions/store"
-import { ChannelId, GuildId, ThreadId } from "../../../types"
-import { DeliveryError, HistoryError, messageOf, ThreadEnsureError } from "../../model/errors"
-import { ChannelMessage, Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "../../model/schema"
-import { ConversationLedger, History, Inbox, Outbox, Threads } from "../../services"
-
-type ChatChannel = TextChannel | ThreadChannel
-
-const HISTORY_FETCH_LIMIT = 40
-const HISTORY_LINE_CHAR_LIMIT = 500
-const HISTORY_TOTAL_CHAR_LIMIT = 6000
-const INGRESS_DEDUP_LIMIT = 4_000
-const EMPTY_MENTION_REPLY = "Tag me with a question!"
-const SETUP_FAILURE_REPLY = "Something went wrong setting up the thread."
-const COMMAND_NOT_THREAD_REPLY = "Use this command inside a Discord thread."
-const COMMAND_FORBIDDEN_REPLY = "You don't have the required role for this command."
-const COMMAND_CHANNEL_REPLY = "This thread is not allowed for the bot."
-const COMMAND_ACK = "Running command in this thread..."
+import { AppConfig } from "../config"
+import { DiscordClient } from "./client"
+import { TYPING_INTERVAL } from "./constants"
+import { cleanResponse, splitForDiscord } from "./format"
+import { SessionStore } from "../session/store"
+import { ChannelId, GuildId, ThreadId } from "../types"
+import { DeliveryError, HistoryError, messageOf, ThreadEnsureError } from "../conversation/model/errors"
+import { ChannelMessage, Mention, ThreadMessage, ThreadRef, Typing, type Action, type Inbound } from "../conversation/model/schema"
+import { History, Inbox, OffsetStore, Outbox, Threads } from "../conversation"
+import {
+  COMMAND_ACK,
+  COMMAND_CHANNEL_REPLY,
+  COMMAND_FORBIDDEN_REPLY,
+  COMMAND_NOT_THREAD_REPLY,
+  COMMANDS,
+  EMPTY_MENTION_REPLY,
+  SETUP_FAILURE_REPLY,
+  commandText,
+} from "./conversation-commands"
+import {
+  asTextChannel,
+  asThreadChannel,
+  type ChatChannel,
+  hasRequiredRole,
+  isChannelAllowed,
+  isMentioned,
+} from "./conversation-channels"
+import { catchupBenign, deliveryRetriable, deliveryRetry } from "./conversation-delivery"
+import { buildHistoryReplayPrompt } from "./conversation-history"
+import { catchupFromOffset } from "./catchup"
+
+const CACHE_LIMIT = 4_000
 const CATCHUP_PAGE_SIZE = 100
-const COMMANDS = [
-  {
-    name: "status",
-    description: "Show sandbox status for this thread",
-  },
-  {
-    name: "reset",
-    description: "Destroy the sandbox session for this thread",
-  },
-] as const
-
-const commandText = (name: string): string => {
-  if (name === "status") return "!status"
-  if (name === "reset") return "!reset"
-  return ""
-}
-
-const isChannelAllowed = (channelId: string, categoryId: string | null, config: AppConfig.Service): boolean => {
-  if (config.allowedChannelIds.length > 0 && config.allowedChannelIds.includes(channelId)) return true
-  if (config.discordCategoryId && categoryId === config.discordCategoryId) return true
-  return false
-}
-
-const hasRequiredRole = (member: GuildMember | null, config: AppConfig.Service): boolean => {
-  if (!config.discordRequiredRoleId) return true
-  if (!member) return false
-  return member.roles.cache.has(config.discordRequiredRoleId)
-}
-
-const asThreadChannel = (value: unknown): ThreadChannel | null => {
-  if (typeof value !== "object" || value === null) return null
-  const type = (value as { type?: unknown }).type
-  if (type === ChannelType.PublicThread || type === ChannelType.PrivateThread) return value as ThreadChannel
-  return null
-}
-
-const asTextChannel = (value: unknown): TextChannel | null => {
-  if (typeof value !== "object" || value === null) return null
-  const type = (value as { type?: unknown }).type
-  if (type === ChannelType.GuildText) return value as TextChannel
-  return null
-}
-
-const isMentioned = (message: Message, botUserId: string, botRoleId: string): boolean => {
-  if (botUserId.length > 0 && message.mentions.users.has(botUserId)) return true
-  if (botRoleId.length > 0 && message.mentions.roles.has(botRoleId)) return true
-  if (botUserId.length > 0 && message.content.includes(`<@${botUserId}>`)) return true
-  if (botUserId.length > 0 && message.content.includes(`<@!${botUserId}>`)) return true
-  if (botRoleId.length > 0 && message.content.includes(`<@&${botRoleId}>`)) return true
-  return false
-}
-
-const buildHistoryReplayPrompt = Effect.fn("DiscordAdapter.buildHistoryReplayPrompt")(
-  function* (channel: ChatChannel, latest: string) {
-    const fetched = yield* Effect.tryPromise(() => channel.messages.fetch({ limit: HISTORY_FETCH_LIMIT }))
-    const ordered = [...fetched.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp)
-    const lines = ordered
-      .filter((prior) => !prior.system)
-      .flatMap((prior) => {
-        const text = prior.content.replace(/\s+/g, " ").trim()
-        const files = prior.attachments.size > 0
-          ? `[attachments: ${[...prior.attachments.values()].map((att) => att.name ?? "file").join(", ")}]`
-          : ""
-        const line = text || files
-        if (!line) return []
-        const value = line.length > HISTORY_LINE_CHAR_LIMIT ? `${line.slice(0, HISTORY_LINE_CHAR_LIMIT)}...` : line
-        return [`${prior.author.bot ? "assistant" : "user"}: ${value}`]
-      })
-
-    const prior = lines.at(-1) === `user: ${latest}` ? lines.slice(0, -1) : lines
-    if (prior.length === 0) return latest
-
-    const selected = prior.reduceRight(
-      (state, candidate) => {
-        if (state.stop) return state
-        if (state.total + candidate.length > HISTORY_TOTAL_CHAR_LIMIT && state.list.length > 0) {
-          return { ...state, stop: true }
-        }
-        return { list: [candidate, ...state.list], total: state.total + candidate.length, stop: false }
-      },
-      { list: [] as ReadonlyArray<string>, total: 0, stop: false },
-    ).list
-
-    return [
-      "Conversation history from this same Discord thread (oldest to newest):",
-      selected.join("\n"),
-      "",
-      "Continue the same conversation and respond to the latest user message:",
-      latest,
-    ].join("\n")
-  },
-)
-
-const statusOf = (cause: unknown): number | null => {
-  if (typeof cause !== "object" || cause === null) return null
-  const status = (cause as { status?: unknown }).status
-  if (typeof status === "number") return status
-  const code = (cause as { code?: unknown }).code
-  if (typeof code === "number") return code
-  return null
-}
-
-const deliveryRetriable = (cause: unknown): boolean => {
-  const status = statusOf(cause)
-  if (status === 429) return true
-  if (status !== null && status >= 500) return true
-  return false
-}
-
-const catchupBenign = (cause: unknown): boolean => {
-  const text = messageOf(cause).toLowerCase()
-  if (text.includes("missing access")) return true
-  if (text.includes("missing permissions")) return true
-  if (text.includes("unknown channel")) return true
-  if (text.includes("50001")) return true
-  if (text.includes("50013")) return true
-  return false
-}
-
-const deliveryRetry = Schedule.exponential("200 millis").pipe(
-  Schedule.intersect(Schedule.recurs(3)),
-  Schedule.whileInput((error: DeliveryError) => error.retriable),
-)
 
 export class DiscordConversationServices {
   static readonly portLayer = Layer.scopedContext(
     Effect.gen(function* () {
       const client = yield* DiscordClient
       const config = yield* AppConfig
-      const pool = yield* ThreadAgentPool
       const sessions = yield* SessionStore
-      const ledger = yield* ConversationLedger
+      const offsets = yield* OffsetStore
       const runtime = yield* Effect.runtime<never>()
       const input = yield* Queue.unbounded<Inbound>()
       const chats = new Map<string, ChatChannel>()
       const texts = new Map<string, TextChannel>()
       const refs = new Map<string, Message>()
       const roots = new Map<string, ThreadId>()
-      const seen = new Set<string>()
-      const order: Array<string> = []
       const ref_ids: Array<string> = []
       const root_ids: Array<string> = []
 
-      const mark = (message_id: string): boolean => {
-        if (seen.has(message_id)) return false
-        seen.add(message_id)
-        order.push(message_id)
-        if (order.length <= INGRESS_DEDUP_LIMIT) return true
-        const oldest = order.shift()
-        if (!oldest) return true
-        seen.delete(oldest)
-        return true
-      }
-
       const stash = <A>(map: Map<string, A>, keys: Array<string>, key: string, value: A) => {
         if (!map.has(key)) keys.push(key)
         map.set(key, value)
-        if (keys.length <= INGRESS_DEDUP_LIMIT) return
+        if (keys.length <= CACHE_LIMIT) return
         const oldest = keys.shift()
         if (!oldest) return
         map.delete(oldest)
       }
 
-      const sourceChannel = (channel_id: string) => `channel:${channel_id}`
-      const sourceThread = (thread_id: string) => `thread:${thread_id}`
+      const sourceChannel = (channelId: string) => `channel:${channelId}`
+      const sourceThread = (threadId: string) => `thread:${threadId}`
       const uniq = <A>(values: ReadonlyArray<A>): Array<A> => [...new Set(values)]
 
       const offer = (event: Inbound, onFresh: Effect.Effect<void>) =>
-        ledger.dedup(event.message_id).pipe(
-          Effect.flatMap((fresh) => {
-            if (!fresh) {
-              return Effect.logDebug("Message deduped (already seen)").pipe(
-                Effect.annotateLogs({
-                  event: "conversation.message.deduped",
-                  message_id: event.message_id,
-                }),
-              )
-            }
-            return Effect.logInfo("Message queued").pipe(
-              Effect.annotateLogs({
-                event: "conversation.message.queued",
-                kind: event.kind,
-                message_id: event.message_id,
-                author_id: event.author_id,
-                content: event.content.slice(0, 200),
-              }),
-            ).pipe(
-              Effect.zipRight(onFresh),
-              Effect.zipRight(input.offer(event)),
-              Effect.asVoid,
-            )
+        Effect.logInfo("Message queued").pipe(
+          Effect.annotateLogs({
+            event: "conversation.message.queued",
+            kind: event.kind,
+            message_id: event.messageId,
+            author_id: event.authorId,
+            content: event.content.slice(0, 200),
           }),
+          Effect.zipRight(onFresh),
+          Effect.zipRight(input.offer(event)),
+          Effect.asVoid,
         )
 
       const memberOf = (message: Message) => {
@@ -238,102 +97,103 @@ export class DiscordConversationServices {
         if (source === null) return
 
         if (message.author.bot || message.mentions.everyone) {
-          yield* ledger.setOffset(source, message.id)
+          yield* offsets.setOffset(source, message.id)
           return
         }
         const member = yield* memberOf(message)
         if (!hasRequiredRole(member, config)) {
-          yield* ledger.setOffset(source, message.id)
+          yield* offsets.setOffset(source, message.id)
           return
         }
 
-        const bot_user_id = client.user?.id ?? ""
-        const bot_role_id = config.discordRoleId
-        const mentioned = isMentioned(message, bot_user_id, bot_role_id)
+        const botUserId = client.user?.id ?? ""
+        const botRoleId = config.discordRoleId
+        const mentioned = isMentioned(message, botUserId, botRoleId)
         const content = message.content.replace(/<@[!&]?\d+>/g, "").trim()
         const mentions = Mention.make({
-          user_ids: [...message.mentions.users.keys()],
-          role_ids: [...message.mentions.roles.keys()],
+          userIds: [...message.mentions.users.keys()],
+          roleIds: [...message.mentions.roles.keys()],
         })
 
         if (!content && mentioned) {
           yield* Effect.tryPromise(() => message.reply(EMPTY_MENTION_REPLY)).pipe(Effect.catchAll(() => Effect.void))
-          yield* ledger.setOffset(source, message.id)
+          yield* offsets.setOffset(source, message.id)
           return
         }
 
         if (message.channel.type === ChannelType.PublicThread || message.channel.type === ChannelType.PrivateThread) {
-          const thread = message.channel as ThreadChannel
-          const thread_id = ThreadId.make(thread.id)
-          const channel_id = ChannelId.make(thread.parentId ?? thread.id)
+          const thread = asThreadChannel(message.channel)
+          if (!thread) return
+          const threadId = ThreadId.make(thread.id)
+          const channelId = ChannelId.make(thread.parentId ?? thread.id)
           const allowed = isChannelAllowed(thread.parentId ?? "", thread.parent?.parentId ?? null, config)
 
           if (!allowed) {
-            const owned = yield* pool.hasTrackedThread(thread_id).pipe(
+            const owned = yield* sessions.hasTrackedThread(threadId).pipe(
               Effect.catchAll(() => Effect.succeed(false)),
             )
             if (!owned || mentioned) {
-              yield* ledger.setOffset(source, message.id)
+              yield* offsets.setOffset(source, message.id)
               return
             }
           }
 
           const event = ThreadMessage.make({
             kind: "thread_message",
-            thread_id,
-            channel_id,
-            message_id: message.id,
-            guild_id: GuildId.make(message.guildId ?? ""),
-            bot_user_id,
-            bot_role_id,
-            author_id: message.author.id,
-            author_is_bot: message.author.bot,
-            mentions_everyone: message.mentions.everyone,
+            threadId,
+            channelId,
+            messageId: message.id,
+            guildId: GuildId.make(message.guildId ?? ""),
+            botUserId,
+            botRoleId,
+            authorId: message.author.id,
+            authorIsBot: message.author.bot,
+            mentionsEveryone: message.mentions.everyone,
             mentions,
             content,
           })
           yield* offer(
             event,
             Effect.sync(() => {
-              chats.set(event.thread_id, thread)
-              stash(refs, ref_ids, event.message_id, message)
+              chats.set(event.threadId, thread)
+              stash(refs, ref_ids, event.messageId, message)
             }),
           )
-          yield* ledger.setOffset(source, message.id)
+          yield* offsets.setOffset(source, message.id)
           return
         }
 
-        const channel = message.channel as TextChannel
+        const channel = asTextChannel(message.channel)
+        if (!channel) return
         if (!isChannelAllowed(channel.id, channel.parentId ?? null, config)) {
-          yield* ledger.setOffset(source, message.id)
+          yield* offsets.setOffset(source, message.id)
           return
         }
 
         const event = ChannelMessage.make({
           kind: "channel_message",
-          channel_id: ChannelId.make(channel.id),
-          message_id: message.id,
-          guild_id: GuildId.make(message.guildId ?? ""),
-          bot_user_id,
-          bot_role_id,
-          author_id: message.author.id,
-          author_is_bot: message.author.bot,
-          mentions_everyone: message.mentions.everyone,
+          channelId: ChannelId.make(channel.id),
+          messageId: message.id,
+          guildId: GuildId.make(message.guildId ?? ""),
+          botUserId,
+          botRoleId,
+          authorId: message.author.id,
+          authorIsBot: message.author.bot,
+          mentionsEveryone: message.mentions.everyone,
           mentions,
           content,
         })
         yield* offer(
           event,
           Effect.sync(() => {
-            texts.set(event.channel_id, channel)
-            stash(refs, ref_ids, event.message_id, message)
+            texts.set(event.channelId, channel)
+            stash(refs, ref_ids, event.messageId, message)
           }),
         )
-        yield* ledger.setOffset(source, message.id)
+        yield* offsets.setOffset(source, message.id)
       })
 
       const onMessage = (message: Message): void => {
-        if (!mark(message.id)) return
         const run = ingestMessage(message).pipe(
           Effect.catchAll((error) =>
             Effect.logError("Failed ingesting Discord message").pipe(
@@ -347,38 +207,28 @@ export class DiscordConversationServices {
         void Runtime.runPromise(runtime)(run)
       }
 
-      const pullAfter = (channel: ChatChannel, after: string): Effect.Effect<number, unknown> =>
-        Effect.tryPromise(() =>
-          channel.messages.fetch({
-            limit: CATCHUP_PAGE_SIZE,
-            after,
-          })
-        ).pipe(
-          Effect.map((page) => [...page.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp)),
-          Effect.flatMap((rows) => {
-            if (rows.length === 0) return Effect.succeed(0)
-            const last = rows.at(-1)
-            if (!last) return Effect.succeed(0)
-            return Effect.forEach(rows, (row) => ingestMessage(row), { discard: true }).pipe(
-              Effect.zipRight(
-                rows.length < CATCHUP_PAGE_SIZE
-                  ? Effect.succeed(rows.length)
-                  : pullAfter(channel, last.id).pipe(Effect.map((tail: number) => rows.length + tail)),
-              ),
-            )
-          }),
-        )
-
       const catchupSource = (source: string, channel: ChatChannel) =>
-        Effect.gen(function* () {
-          const offset = yield* ledger.getOffset(source)
-          if (Option.isNone(offset)) {
-            const page = yield* Effect.tryPromise(() => channel.messages.fetch({ limit: 1 }))
-            const latest = page.first()
-            if (latest) yield* ledger.setOffset(source, latest.id)
-            return 0
-          }
-          return yield* pullAfter(channel, offset.value)
+        catchupFromOffset({
+          source,
+          pageSize: CATCHUP_PAGE_SIZE,
+          offsets,
+          fetchLatest: Effect.tryPromise(() => channel.messages.fetch({ limit: 1 })).pipe(
+            Effect.map((page) => {
+              const latest = page.first()
+              return latest ? Option.some(latest) : Option.none()
+            }),
+          ),
+          fetchAfter: (after) =>
+            Effect.tryPromise(() =>
+              channel.messages.fetch({
+                limit: CATCHUP_PAGE_SIZE,
+                after,
+              })
+            ).pipe(
+              Effect.map((page) => [...page.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp)),
+            ),
+          idOf: (message) => message.id,
+          ingest: (message) => ingestMessage(message),
         })
 
       const categoryChannels = () =>
@@ -404,14 +254,14 @@ export class DiscordConversationServices {
           return nested.flat()
         })
 
-      const fetchText = (channel_id: string) =>
-        Effect.tryPromise(() => client.channels.fetch(channel_id)).pipe(
+      const fetchText = (channelId: string) =>
+        Effect.tryPromise(() => client.channels.fetch(channelId)).pipe(
           Effect.map((channel) => asTextChannel(channel)),
           Effect.catchAll(() => Effect.succeed(null)),
         )
 
-      const fetchThread = (thread_id: string) =>
-        Effect.tryPromise(() => client.channels.fetch(thread_id)).pipe(
+      const fetchThread = (threadId: string) =>
+        Effect.tryPromise(() => client.channels.fetch(threadId)).pipe(
           Effect.map((channel) => asThreadChannel(channel)),
           Effect.catchAll(() => Effect.succeed(null)),
         )
@@ -424,18 +274,18 @@ export class DiscordConversationServices {
 
         const fromChannels = yield* Effect.forEach(
           channels,
-          (channel_id) =>
-            fetchText(channel_id).pipe(
+          (channelId) =>
+            fetchText(channelId).pipe(
               Effect.flatMap((channel) => {
                 if (!channel) return Effect.succeed(0)
-                return catchupSource(sourceChannel(channel_id), channel)
+                return catchupSource(sourceChannel(channelId), channel)
               }),
               Effect.catchAll((error) => {
                 const log = catchupBenign(error) ? Effect.logDebug("Channel catch-up skipped") : Effect.logWarning("Channel catch-up failed")
                 return log.pipe(
                   Effect.annotateLogs({
                     event: "conversation.catchup.channel.failed",
-                    channel_id,
+                    channel_id: channelId,
                     error: messageOf(error),
                   }),
                   Effect.as(0),
@@ -447,18 +297,18 @@ export class DiscordConversationServices {
 
         const fromThreads = yield* Effect.forEach(
           threads,
-          (thread_id) =>
-            fetchThread(thread_id).pipe(
+          (threadId) =>
+            fetchThread(threadId).pipe(
               Effect.flatMap((thread) => {
                 if (!thread) return Effect.succeed(0)
-                return catchupSource(sourceThread(thread_id), thread)
+                return catchupSource(sourceThread(threadId), thread)
               }),
               Effect.catchAll((error) => {
                 const log = catchupBenign(error) ? Effect.logDebug("Thread catch-up skipped") : Effect.logWarning("Thread catch-up failed")
                 return log.pipe(
                   Effect.annotateLogs({
                     event: "conversation.catchup.thread.failed",
-                    thread_id,
+                    thread_id: threadId,
                     error: messageOf(error),
                   }),
                   Effect.as(0),
@@ -492,7 +342,6 @@ export class DiscordConversationServices {
         if (!interaction.isChatInputCommand()) return
         const text = commandText(interaction.commandName)
         if (!text) return
-        if (!mark(interaction.id)) return
         const handle = Effect.gen(function* () {
           yield* Effect.tryPromise(() =>
             interaction.deferReply({
@@ -504,11 +353,11 @@ export class DiscordConversationServices {
             yield* acknowledge(interaction, COMMAND_NOT_THREAD_REPLY)
             return
           }
-          const thread_id = ThreadId.make(thread.id)
-          const channel_id = ChannelId.make(thread.parentId ?? thread.id)
+          const threadId = ThreadId.make(thread.id)
+          const channelId = ChannelId.make(thread.parentId ?? thread.id)
           const allowed = isChannelAllowed(thread.parentId ?? "", thread.parent?.parentId ?? null, config)
           if (!allowed) {
-            const owned = yield* pool.hasTrackedThread(thread_id).pipe(
+            const owned = yield* sessions.hasTrackedThread(threadId).pipe(
               Effect.catchAll(() => Effect.succeed(false)),
             )
             if (!owned) {
@@ -525,35 +374,28 @@ export class DiscordConversationServices {
             return
           }
 
-          const bot_user_id = client.user?.id ?? ""
+          const botUserId = client.user?.id ?? ""
           const event = ThreadMessage.make({
             kind: "thread_message",
-            thread_id,
-            channel_id,
-            message_id: interaction.id,
-            guild_id: GuildId.make(interaction.guildId ?? ""),
-            bot_user_id,
-            bot_role_id: config.discordRoleId,
-            author_id: interaction.user.id,
-            author_is_bot: false,
-            mentions_everyone: false,
+            threadId,
+            channelId,
+            messageId: interaction.id,
+            guildId: GuildId.make(interaction.guildId ?? ""),
+            botUserId,
+            botRoleId: config.discordRoleId,
+            authorId: interaction.user.id,
+            authorIsBot: false,
+            mentionsEveryone: false,
             mentions: Mention.make({
-              user_ids: bot_user_id.length > 0 ? [bot_user_id] : [],
-              role_ids: [],
+              userIds: botUserId.length > 0 ? [botUserId] : [],
+              roleIds: [],
             }),
             content: text,
           })
-          const ingest = ledger.dedup(event.message_id).pipe(
-            Effect.flatMap((fresh) => {
-              if (!fresh) return Effect.void
-              return Effect.sync(() => {
-                chats.set(event.thread_id, thread)
-                input.unsafeOffer(event)
-              })
-            }),
-            Effect.catchAll(() => Effect.void),
-          )
-          yield* ingest
+          yield* Effect.sync(() => {
+            chats.set(event.threadId, thread)
+            input.unsafeOffer(event)
+          })
           yield* acknowledge(interaction, COMMAND_ACK)
         })
         void Runtime.runPromise(runtime)(handle)
@@ -621,18 +463,18 @@ export class DiscordConversationServices {
         events: Stream.fromQueue(input, { shutdown: false }),
       })
 
-      const channelOf = (thread_id: ThreadId, action: Action["kind"]) => {
-        const channel = chats.get(thread_id)
+      const channelOf = (threadId: ThreadId, action: Action["kind"]) => {
+        const channel = chats.get(threadId)
         if (channel) return Effect.succeed(channel)
-        return Effect.tryPromise(() => client.channels.fetch(thread_id)).pipe(
+        return Effect.tryPromise(() => client.channels.fetch(threadId)).pipe(
           Effect.flatMap((fetched) => {
             const thread = asThreadChannel(fetched)
             if (thread) {
-              chats.set(thread_id, thread)
+              chats.set(threadId, thread)
               return Effect.succeed(thread)
             }
             return DeliveryError.make({
-              thread_id,
+              threadId,
               action,
               message: "missing-thread-channel",
               retriable: false,
@@ -640,7 +482,7 @@ export class DiscordConversationServices {
           }),
           Effect.mapError((cause) =>
             DeliveryError.make({
-              thread_id,
+              threadId,
               action,
               message: messageOf(cause),
               retriable: deliveryRetriable(cause),
@@ -648,13 +490,13 @@ export class DiscordConversationServices {
         )
       }
 
-      const deliver = (thread_id: ThreadId, action: Action["kind"], send: Effect.Effect<unknown, unknown>) =>
+      const deliver = (threadId: ThreadId, action: Action["kind"], send: Effect.Effect<unknown, unknown>) =>
         Effect.gen(function* () {
           const attempts = yield* Ref.make(0)
           yield* send.pipe(
             Effect.mapError((cause) =>
               DeliveryError.make({
-                thread_id,
+                threadId,
                 action,
                 message: messageOf(cause),
                 retriable: deliveryRetriable(cause),
@@ -665,7 +507,7 @@ export class DiscordConversationServices {
                   Effect.logWarning("Discord delivery attempt failed").pipe(
                     Effect.annotateLogs({
                       event: "conversation.delivery.retry",
-                      thread_id,
+                      thread_id: threadId,
                       action,
                       attempt,
                       retriable: error.retriable,
@@ -681,7 +523,7 @@ export class DiscordConversationServices {
                   Effect.logError("Discord delivery failed").pipe(
                     Effect.annotateLogs({
                       event: "conversation.delivery.failed",
-                      thread_id,
+                      thread_id: threadId,
                       action,
                       attempts: attempt,
                       retriable: error.retriable,
@@ -693,34 +535,34 @@ export class DiscordConversationServices {
           )
         })
 
-      const sendTyping = (thread_id: ThreadId) =>
+      const sendTyping = (threadId: ThreadId) =>
         Effect.gen(function* () {
-          const channel = yield* channelOf(thread_id, "typing")
-          yield* deliver(thread_id, "typing", Effect.tryPromise(() => channel.sendTyping()))
+          const channel = yield* channelOf(threadId, "typing")
+          yield* deliver(threadId, "typing", Effect.tryPromise(() => channel.sendTyping()))
         })
 
-      const sendText = (thread_id: ThreadId, action: "send" | "reply", text: string) =>
+      const sendText = (threadId: ThreadId, action: "send" | "reply", text: string) =>
         Effect.gen(function* () {
-          const channel = yield* channelOf(thread_id, action)
+          const channel = yield* channelOf(threadId, action)
           yield* Effect.forEach(
             splitForDiscord(cleanResponse(text)),
-            (chunk) => deliver(thread_id, action, Effect.tryPromise(() => channel.send(chunk))),
+            (chunk) => deliver(threadId, action, Effect.tryPromise(() => channel.send(chunk))),
             { discard: true },
           )
         })
 
       const publish = (action: Action) => {
-        if (action.kind === "typing") return sendTyping(action.thread_id)
-        return sendText(action.thread_id, action.kind, action.text)
+        if (action.kind === "typing") return sendTyping(action.threadId)
+        return sendText(action.threadId, action.kind, action.text)
       }
 
-      const withTyping = <A, E, R>(thread_id: ThreadId, self: Effect.Effect<A, E, R>) =>
+      const withTyping = <A, E, R>(threadId: ThreadId, self: Effect.Effect<A, E, R>) =>
         Effect.scoped(
           Effect.gen(function* () {
             const pulse = publish(
               Typing.make({
                 kind: "typing",
-                thread_id,
+                threadId,
               }),
             ).pipe(Effect.catchAll(() => Effect.void))
             yield* pulse
@@ -736,14 +578,14 @@ export class DiscordConversationServices {
       const outbox = Outbox.of({ publish, withTyping })
 
       const history = History.of({
-        rehydrate: (thread_id, latest: string) =>
+        rehydrate: (threadId, latest: string) =>
           Effect.gen(function* () {
-            const channel = chats.get(thread_id)
+            const channel = chats.get(threadId)
             if (!channel) return latest
             return yield* buildHistoryReplayPrompt(channel, latest).pipe(
               Effect.mapError((cause) =>
                 HistoryError.make({
-                  thread_id,
+                  threadId,
                   message: messageOf(cause),
                   retriable: true,
                 })),
@@ -754,40 +596,40 @@ export class DiscordConversationServices {
       const threads = Threads.of({
         ensure: (event, name: string) => {
           if (event.kind === "thread_message") {
-            return Effect.succeed(ThreadRef.make({ thread_id: event.thread_id, channel_id: event.channel_id }))
+            return Effect.succeed(ThreadRef.make({ threadId: event.threadId, channelId: event.channelId }))
           }
 
-          const known = roots.get(event.message_id)
+          const known = roots.get(event.messageId)
           if (known) {
-            return Effect.succeed(ThreadRef.make({ thread_id: known, channel_id: event.channel_id }))
+            return Effect.succeed(ThreadRef.make({ threadId: known, channelId: event.channelId }))
           }
 
           return Effect.gen(function* () {
-            const local = texts.get(event.channel_id)
+            const local = texts.get(event.channelId)
             const channel = local
               ? local
-              : yield* Effect.tryPromise(() => client.channels.fetch(event.channel_id)).pipe(
+              : yield* Effect.tryPromise(() => client.channels.fetch(event.channelId)).pipe(
                 Effect.map((fetched) => asTextChannel(fetched)),
                 Effect.mapError((cause) =>
                   ThreadEnsureError.make({
-                    channel_id: event.channel_id,
+                    channelId: event.channelId,
                     message: messageOf(cause),
                     retriable: deliveryRetriable(cause),
                   })),
               )
             if (!channel) {
               return yield* ThreadEnsureError.make({
-                channel_id: event.channel_id,
+                channelId: event.channelId,
                 message: "missing-parent-channel",
                 retriable: false,
               })
             }
-            texts.set(event.channel_id, channel)
-            const base = refs.get(event.message_id)
+            texts.set(event.channelId, channel)
+            const base = refs.get(event.messageId)
             const thread = yield* Effect.tryPromise(() =>
               channel.threads.create({
                 name,
-                startMessage: base ?? event.message_id,
+                startMessage: base ?? event.messageId,
                 autoArchiveDuration: 60,
               }),
             ).pipe(
@@ -801,15 +643,15 @@ export class DiscordConversationServices {
                 )),
               Effect.mapError((cause) =>
                 ThreadEnsureError.make({
-                  channel_id: event.channel_id,
+                  channelId: event.channelId,
                   message: messageOf(cause),
                   retriable: deliveryRetriable(cause),
                 })),
             )
-            const thread_id = ThreadId.make(thread.id)
-            chats.set(thread_id, thread)
-            stash(roots, root_ids, event.message_id, thread_id)
-            return ThreadRef.make({ thread_id, channel_id: event.channel_id })
+            const threadId = ThreadId.make(thread.id)
+            chats.set(threadId, thread)
+            stash(roots, root_ids, event.messageId, threadId)
+            return ThreadRef.make({ threadId, channelId: event.channelId })
           })
         },
       })

+ 70 - 0
packages/discord/src/discord/catchup.test.ts

@@ -0,0 +1,70 @@
+import { describe, expect } from "bun:test"
+import { Effect, Option } from "effect"
+import type { OffsetStore } from "../conversation/offsets"
+import { effectTest } from "../test/effect"
+import { catchupFromOffset } from "./catchup"
+
+const makeOffsets = () => {
+  const map = new Map<string, string>()
+  const service: OffsetStore.Service = {
+    getOffset: (source_id) => Effect.succeed(Option.fromNullable(map.get(source_id))),
+    setOffset: (source_id, messageId) =>
+      Effect.sync(() => {
+        map.set(source_id, messageId)
+      }),
+  }
+  return { service, map }
+}
+
+describe("catchupFromOffset", () => {
+  effectTest("no offset seeds latest and does not ingest", () => {
+    const offsets = makeOffsets()
+    const ingested: Array<string> = []
+    return catchupFromOffset({
+      source: "thread:t1",
+      pageSize: 2,
+      offsets: offsets.service,
+      fetchLatest: Effect.succeed(Option.some({ id: "m9" })),
+      fetchAfter: () => Effect.succeed([]),
+      idOf: (message) => message.id,
+      ingest: (message) =>
+        Effect.sync(() => {
+          ingested.push(message.id)
+        }),
+    }).pipe(
+      Effect.tap((count) => Effect.sync(() => {
+        expect(count).toBe(0)
+        expect(ingested).toEqual([])
+        expect(offsets.map.get("thread:t1")).toBe("m9")
+      })),
+    )
+  })
+
+  effectTest("existing offset replays all pages in order", () => {
+    const offsets = makeOffsets()
+    offsets.map.set("thread:t1", "m1")
+    const ingested: Array<string> = []
+    const pages = new Map<string, ReadonlyArray<{ id: string }>>([
+      ["m1", [{ id: "m2" }, { id: "m3" }]],
+      ["m3", [{ id: "m4" }]],
+    ])
+
+    return catchupFromOffset({
+      source: "thread:t1",
+      pageSize: 2,
+      offsets: offsets.service,
+      fetchLatest: Effect.succeed(Option.none()),
+      fetchAfter: (after) => Effect.succeed(pages.get(after) ?? []),
+      idOf: (message) => message.id,
+      ingest: (message) =>
+        Effect.sync(() => {
+          ingested.push(message.id)
+        }),
+    }).pipe(
+      Effect.tap((count) => Effect.sync(() => {
+        expect(count).toBe(3)
+        expect(ingested).toEqual(["m2", "m3", "m4"])
+      })),
+    )
+  })
+})

+ 42 - 0
packages/discord/src/discord/catchup.ts

@@ -0,0 +1,42 @@
+import { Effect, Option } from "effect"
+import { OffsetStore } from "../conversation/offsets"
+
+export const catchupFromOffset = <M>(input: {
+  source: string
+  pageSize: number
+  offsets: OffsetStore.Service
+  fetchLatest: Effect.Effect<Option.Option<M>, unknown>
+  fetchAfter: (after: string) => Effect.Effect<ReadonlyArray<M>, unknown>
+  idOf: (message: M) => string
+  ingest: (message: M) => Effect.Effect<void, unknown>
+}): Effect.Effect<number, unknown> => {
+  const pull = (after: string): Effect.Effect<number, unknown> =>
+    input.fetchAfter(after).pipe(
+      Effect.flatMap((rows) => {
+        if (rows.length === 0) return Effect.succeed(0)
+        const last = rows.at(-1)
+        if (!last) return Effect.succeed(0)
+        return Effect.forEach(rows, input.ingest, { discard: true }).pipe(
+          Effect.zipRight(
+            rows.length < input.pageSize
+              ? Effect.succeed(rows.length)
+              : pull(input.idOf(last)).pipe(Effect.map((tail) => rows.length + tail)),
+          ),
+        )
+      }),
+    )
+
+  return input.offsets.getOffset(input.source).pipe(
+    Effect.flatMap((offset) => {
+      if (Option.isNone(offset)) {
+        return input.fetchLatest.pipe(
+          Effect.flatMap((latest) => {
+            if (Option.isNone(latest)) return Effect.succeed(0)
+            return input.offsets.setOffset(input.source, input.idOf(latest.value)).pipe(Effect.as(0))
+          }),
+        )
+      }
+      return pull(offset.value)
+    }),
+  )
+}

+ 40 - 0
packages/discord/src/discord/conversation-channels.ts

@@ -0,0 +1,40 @@
+import { ChannelType } from "discord.js"
+import type { GuildMember, Message, TextChannel, ThreadChannel } from "discord.js"
+import { AppConfig } from "../config"
+
+export type ChatChannel = TextChannel | ThreadChannel
+
+export const isChannelAllowed = (channelId: string, categoryId: string | null, config: AppConfig.Service): boolean => {
+  if (config.allowedChannelIds.length > 0 && config.allowedChannelIds.includes(channelId)) return true
+  if (config.discordCategoryId && categoryId === config.discordCategoryId) return true
+  return false
+}
+
+export const hasRequiredRole = (member: GuildMember | null, config: AppConfig.Service): boolean => {
+  if (!config.discordRequiredRoleId) return true
+  if (!member) return false
+  return member.roles.cache.has(config.discordRequiredRoleId)
+}
+
+export const asThreadChannel = (value: unknown): ThreadChannel | null => {
+  if (typeof value !== "object" || value === null) return null
+  const type = (value as { type?: unknown }).type
+  if (type === ChannelType.PublicThread || type === ChannelType.PrivateThread) return value as ThreadChannel
+  return null
+}
+
+export const asTextChannel = (value: unknown): TextChannel | null => {
+  if (typeof value !== "object" || value === null) return null
+  const type = (value as { type?: unknown }).type
+  if (type === ChannelType.GuildText) return value as TextChannel
+  return null
+}
+
+export const isMentioned = (message: Message, botUserId: string, botRoleId: string): boolean => {
+  if (botUserId.length > 0 && message.mentions.users.has(botUserId)) return true
+  if (botRoleId.length > 0 && message.mentions.roles.has(botRoleId)) return true
+  if (botUserId.length > 0 && message.content.includes(`<@${botUserId}>`)) return true
+  if (botUserId.length > 0 && message.content.includes(`<@!${botUserId}>`)) return true
+  if (botRoleId.length > 0 && message.content.includes(`<@&${botRoleId}>`)) return true
+  return false
+}

+ 23 - 0
packages/discord/src/discord/conversation-commands.ts

@@ -0,0 +1,23 @@
+export const EMPTY_MENTION_REPLY = "Tag me with a question!"
+export const SETUP_FAILURE_REPLY = "Something went wrong setting up the thread."
+export const COMMAND_NOT_THREAD_REPLY = "Use this command inside a Discord thread."
+export const COMMAND_FORBIDDEN_REPLY = "You don't have the required role for this command."
+export const COMMAND_CHANNEL_REPLY = "This thread is not allowed for the bot."
+export const COMMAND_ACK = "Running command in this thread..."
+
+export const COMMANDS = [
+  {
+    name: "status",
+    description: "Show sandbox status for this thread",
+  },
+  {
+    name: "reset",
+    description: "Destroy the sandbox session for this thread",
+  },
+] as const
+
+export const commandText = (name: string): string => {
+  if (name === "status") return "!status"
+  if (name === "reset") return "!reset"
+  return ""
+}

+ 33 - 0
packages/discord/src/discord/conversation-delivery.ts

@@ -0,0 +1,33 @@
+import { Schedule } from "effect"
+import { DeliveryError, messageOf } from "../conversation/model/errors"
+
+const statusOf = (cause: unknown): number | null => {
+  if (typeof cause !== "object" || cause === null) return null
+  const status = (cause as { status?: unknown }).status
+  if (typeof status === "number") return status
+  const code = (cause as { code?: unknown }).code
+  if (typeof code === "number") return code
+  return null
+}
+
+export const deliveryRetriable = (cause: unknown): boolean => {
+  const status = statusOf(cause)
+  if (status === 429) return true
+  if (status !== null && status >= 500) return true
+  return false
+}
+
+export const catchupBenign = (cause: unknown): boolean => {
+  const text = messageOf(cause).toLowerCase()
+  if (text.includes("missing access")) return true
+  if (text.includes("missing permissions")) return true
+  if (text.includes("unknown channel")) return true
+  if (text.includes("50001")) return true
+  if (text.includes("50013")) return true
+  return false
+}
+
+export const deliveryRetry = Schedule.exponential("200 millis").pipe(
+  Schedule.intersect(Schedule.recurs(3)),
+  Schedule.whileInput((error: DeliveryError) => error.retriable),
+)

+ 47 - 0
packages/discord/src/discord/conversation-history.ts

@@ -0,0 +1,47 @@
+import { Effect } from "effect"
+import { type ChatChannel } from "./conversation-channels"
+
+const HISTORY_FETCH_LIMIT = 40
+const HISTORY_LINE_CHAR_LIMIT = 500
+const HISTORY_TOTAL_CHAR_LIMIT = 6000
+
+export const buildHistoryReplayPrompt = Effect.fn("DiscordAdapter.buildHistoryReplayPrompt")(
+  function* (channel: ChatChannel, latest: string) {
+    const fetched = yield* Effect.tryPromise(() => channel.messages.fetch({ limit: HISTORY_FETCH_LIMIT }))
+    const ordered = [...fetched.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp)
+    const lines = ordered
+      .filter((prior) => !prior.system)
+      .flatMap((prior) => {
+        const text = prior.content.replace(/\s+/g, " ").trim()
+        const files = prior.attachments.size > 0
+          ? `[attachments: ${[...prior.attachments.values()].map((att) => att.name ?? "file").join(", ")}]`
+          : ""
+        const line = text || files
+        if (!line) return []
+        const value = line.length > HISTORY_LINE_CHAR_LIMIT ? `${line.slice(0, HISTORY_LINE_CHAR_LIMIT)}...` : line
+        return [`${prior.author.bot ? "assistant" : "user"}: ${value}`]
+      })
+
+    const prior = lines.at(-1) === `user: ${latest}` ? lines.slice(0, -1) : lines
+    if (prior.length === 0) return latest
+
+    const selected = prior.reduceRight(
+      (state, candidate) => {
+        if (state.stop) return state
+        if (state.total + candidate.length > HISTORY_TOTAL_CHAR_LIMIT && state.list.length > 0) {
+          return { ...state, stop: true }
+        }
+        return { list: [candidate, ...state.list], total: state.total + candidate.length, stop: false }
+      },
+      { list: [] as ReadonlyArray<string>, total: 0, stop: false },
+    ).list
+
+    return [
+      "Conversation history from this same Discord thread (oldest to newest):",
+      selected.join("\n"),
+      "",
+      "Continue the same conversation and respond to the latest user message:",
+      latest,
+    ].join("\n")
+  },
+)

+ 17 - 0
packages/discord/src/errors.ts

@@ -45,15 +45,32 @@ export class HealthCheckError extends Schema.TaggedError<HealthCheckError>()(
   },
 ) {}
 
+export const OpenCodeFailureKind = Schema.Literal("session-missing", "sandbox-down", "non-recoverable")
+export type OpenCodeFailureKind = typeof OpenCodeFailureKind.Type
+
+export const classifyOpenCodeFailure = (statusCode: number, body: string): OpenCodeFailureKind => {
+  if (statusCode === 404) return "session-missing"
+  if (statusCode === 0 || statusCode >= 500) return "sandbox-down"
+  const text = body.toLowerCase()
+  if (text.includes("sandbox not found") || text.includes("is the sandbox started")) return "sandbox-down"
+  return "non-recoverable"
+}
+
 export class OpenCodeClientError extends Schema.TaggedError<OpenCodeClientError>()(
   "OpenCodeClientError",
   {
     operation: Schema.String,
     statusCode: Schema.Number,
     body: Schema.String,
+    kind: OpenCodeFailureKind,
   },
 ) {}
 
+export const isOpenCodeSandboxUnavailable = (error: OpenCodeClientError) => {
+  if (error.kind === "session-missing") return true
+  return error.kind === "sandbox-down"
+}
+
 export class SessionMissingError extends Schema.TaggedError<SessionMissingError>()(
   "SessionMissingError",
   {

+ 5 - 4
packages/discord/src/http/health.ts

@@ -3,7 +3,7 @@ import { BunHttpServer } from "@effect/platform-bun"
 import { Context, Effect, Layer } from "effect"
 import { AppConfig } from "../config"
 import { DiscordClient } from "../discord/client"
-import { ThreadAgentPool } from "../sandbox/pool"
+import { SessionStore } from "../session/store"
 
 export declare namespace HealthServer {
   export interface Service {
@@ -17,7 +17,7 @@ export class HealthServer extends Context.Tag("@discord/HealthServer")<HealthSer
     Effect.gen(function* () {
       const config = yield* AppConfig
       const client = yield* DiscordClient
-      const pool = yield* ThreadAgentPool
+      const sessions = yield* SessionStore
       const startedAt = Date.now()
 
       const routes = HttpLayerRouter.use((router) =>
@@ -26,8 +26,9 @@ export class HealthServer extends Context.Tag("@discord/HealthServer")<HealthSer
             "GET",
             "/healthz",
             Effect.gen(function* () {
-              const activeSessions = yield* pool.getActiveSessionCount().pipe(
-                Effect.catchAll(() => Effect.succeed(0)),
+              const activeSessions = yield* sessions.listActive().pipe(
+                Effect.catchAll(() => Effect.succeed([])),
+                Effect.map((rows) => rows.length),
               )
               return HttpServerResponse.unsafeJson({
                 ok: true,

+ 4 - 56
packages/discord/src/index.ts

@@ -1,67 +1,15 @@
-import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic"
-import { FetchHttpClient } from "@effect/platform"
-import { BunContext, BunRuntime } from "@effect/platform-bun"
+import { BunRuntime } from "@effect/platform-bun"
 import { Effect, Layer } from "effect"
-import { DiscordConversationServicesLive } from "./conversation/implementations/discord"
-import { Conversation } from "./conversation/services/conversation"
-import { ConversationLedger } from "./conversation/services/ledger"
-import { AppConfig } from "./config"
-import { SqliteDb } from "./db/client"
+import { AppConversationLayer } from "./app/layers"
+import { Conversation } from "./conversation/conversation"
 import { DiscordClient } from "./discord/client"
-import { TurnRouter } from "./discord/turn-routing"
 import { HealthServer } from "./http/health"
-import { LoggerLive } from "./observability/logger"
-import { DaytonaService } from "./sandbox/daytona"
-import { OpenCodeClient } from "./sandbox/opencode-client"
-import { ThreadAgentPool } from "./sandbox/pool"
-import { SandboxProvisioner } from "./sandbox/provisioner"
-import { SessionStore } from "./sessions/store"
 
-const AnthropicLayer = Layer.unwrapEffect(
-  Effect.gen(function* () {
-    const config = yield* AppConfig
-    return AnthropicLanguageModel.layer({ model: config.turnRoutingModel }).pipe(
-      Layer.provide(AnthropicClient.layer({
-        apiKey: config.openCodeZenApiKey,
-        apiUrl: "https://opencode.ai/zen",
-      })),
-      Layer.provide(FetchHttpClient.layer),
-    )
-  }),
-)
-
-type AppServices =
-  | AppConfig
-  | DiscordClient
-  | HealthServer
-  | OpenCodeClient
-  | SessionStore
-  | DaytonaService
-  | TurnRouter
-  | SandboxProvisioner
-  | ThreadAgentPool
-  | ConversationLedger
-  | Conversation
-
-const BaseLayer = Layer.mergeAll(AppConfig.layer, FetchHttpClient.layer, BunContext.layer, LoggerLive)
-const WithSqlite = Layer.provideMerge(SqliteDb.layer, BaseLayer)
-const WithAnthropic = Layer.provideMerge(AnthropicLayer, WithSqlite)
-const WithDaytona = Layer.provideMerge(DaytonaService.layer, WithAnthropic)
-const WithOpenCode = Layer.provideMerge(OpenCodeClient.layer, WithDaytona)
-const WithRouting = Layer.provideMerge(TurnRouter.layer, WithOpenCode)
-const WithSessions = Layer.provideMerge(SessionStore.layer, WithRouting)
-const WithProvisioner = Layer.provideMerge(SandboxProvisioner.layer, WithSessions)
-const WithSandbox = Layer.provideMerge(ThreadAgentPool.layer, WithProvisioner)
-const WithLedger = Layer.provideMerge(ConversationLedger.layer, WithSandbox)
-const WithDiscord = Layer.provideMerge(DiscordClient.layer, WithLedger)
-const WithDiscordConversation = Layer.provideMerge(DiscordConversationServicesLive, WithDiscord)
-const WithConversation = Layer.provideMerge(Conversation.layer, WithDiscordConversation)
-const AppLayer = Layer.provideMerge(HealthServer.layer, WithConversation) as Layer.Layer<AppServices | SqliteDb, never, never>
+const AppLayer = Layer.provideMerge(HealthServer.layer, AppConversationLayer)
 
 const main = Effect.gen(function* () {
   const client = yield* DiscordClient
   const conversation = yield* Conversation
-  yield* ThreadAgentPool
   yield* HealthServer
 
   yield* Effect.forkScoped(conversation.run)

+ 23 - 0
packages/discord/src/lib/actors/stateful.ts

@@ -0,0 +1,23 @@
+export declare namespace StatefulActor {
+  export interface Stateful<S> {
+    readonly get: () => S
+    readonly set: (state: S) => void
+    readonly update: (f: (state: S) => S) => S
+  }
+}
+
+export const StatefulActor = {
+  make: <S>(initial: S): StatefulActor.Stateful<S> => {
+    let state = initial
+    return {
+      get: () => state,
+      set: (next) => {
+        state = next
+      },
+      update: (f) => {
+        state = f(state)
+        return state
+      },
+    }
+  },
+} as const

+ 30 - 18
packages/discord/src/sandbox/daytona.ts → packages/discord/src/sandbox/daytona/service.ts

@@ -1,8 +1,8 @@
 import { Daytona as DaytonaSDK, Image } from "@daytonaio/sdk"
 import { Context, Effect, Layer, Redacted, Schema } from "effect"
-import { AppConfig } from "../config"
-import { SandboxCreateError, SandboxExecError, SandboxNotFoundError, SandboxStartError } from "../errors"
-import { GuildId, SandboxId, ThreadId, PreviewAccess } from "../types"
+import { AppConfig } from "../../config"
+import { SandboxCreateError, SandboxExecError, SandboxNotFoundError, SandboxStartError } from "../../errors"
+import { GuildId, SandboxId, ThreadId, PreviewAccess } from "../../types"
 
 export class SandboxHandle extends Schema.Class<SandboxHandle>("SandboxHandle")({
   id: SandboxId,
@@ -38,7 +38,7 @@ export declare namespace DaytonaService {
   }
 }
 
-const discordBotImage = Image.base("node:22-bookworm-slim")
+export const discordBotImage = Image.base("node:22-bookworm-slim")
   .runCommands(
     "apt-get update && apt-get install -y git curl && rm -rf /var/lib/apt/lists/*",
     "curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /usr/share/keyrings/githubcli-archive-keyring.gpg && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" > /etc/apt/sources.list.d/github-cli.list && apt-get update && apt-get install -y gh && rm -rf /var/lib/apt/lists/*",
@@ -82,24 +82,36 @@ export class DaytonaService extends Context.Tag("@discord/DaytonaService")<Dayto
 
       const create = Effect.fn("DaytonaService.create")(
         function* (opts: { threadId: ThreadId; guildId: GuildId; timeout: number }) {
+          const base = {
+            labels: { app: "opencord", threadId: opts.threadId, guildId: opts.guildId },
+            autoStopInterval: 0,
+            autoArchiveInterval: 0,
+          }
+          const snapshot = config.daytonaSnapshot.trim()
           const sandbox = yield* Effect.tryPromise({
             try: () =>
-              sdk.create(
-                {
-                  image: discordBotImage,
-                  labels: { app: "opencord", threadId: opts.threadId, guildId: opts.guildId },
-                  autoStopInterval: 0,
-                  autoArchiveInterval: 0,
-                },
-                { timeout: opts.timeout },
-              ),
+              snapshot.length > 0
+                ? sdk.create(
+                    {
+                      ...base,
+                      snapshot,
+                    },
+                    { timeout: opts.timeout },
+                  )
+                : sdk.create(
+                    {
+                      ...base,
+                      image: discordBotImage,
+                    },
+                    { timeout: opts.timeout },
+                  ),
             catch: (cause) => new SandboxCreateError({ cause }),
           })
-        const sandboxId = SandboxId.make(sandbox.id)
-        return yield* toHandle(
-          sandboxId,
-          sandbox,
-          (cause) => new SandboxCreateError({ sandboxId, cause }),
+          const sandboxId = SandboxId.make(sandbox.id)
+          return yield* toHandle(
+            sandboxId,
+            sandbox,
+            (cause) => new SandboxCreateError({ sandboxId, cause }),
           )
         },
       )

+ 44 - 0
packages/discord/src/sandbox/daytona/snapshot.ts

@@ -0,0 +1,44 @@
+import { Daytona as DaytonaSDK } from "@daytonaio/sdk"
+import { discordBotImage } from "./service"
+
+const now = () => new Date().toISOString().slice(0, 10).replaceAll("-", "")
+
+const apiKey = process.env["DAYTONA_API_KEY"]?.trim() ?? ""
+if (apiKey.length === 0) {
+  console.error("DAYTONA_API_KEY is required")
+  process.exit(1)
+}
+
+const name = Bun.argv[2]?.trim() || `opencode-discord-${now()}`
+const regionId = process.env["DAYTONA_REGION_ID"]?.trim() ?? ""
+
+const run = async () => {
+  const sdk = new DaytonaSDK({
+    apiKey,
+    _experimental: {},
+  })
+  const snapshot = await sdk.snapshot.create(
+    regionId.length > 0
+      ? {
+          name,
+          image: discordBotImage,
+          regionId,
+        }
+      : {
+          name,
+          image: discordBotImage,
+        },
+    {
+      onLogs: (chunk) => process.stdout.write(chunk),
+    },
+  )
+  const active = await sdk.snapshot.activate(snapshot)
+  console.log(`snapshot ready: ${active.name}`)
+  console.log(`set DAYTONA_SNAPSHOT=${active.name}`)
+}
+
+void run().catch((error) => {
+  const message = error instanceof Error ? error.message : String(error)
+  console.error(`snapshot creation failed: ${message}`)
+  process.exit(1)
+})

+ 15 - 7
packages/discord/src/sandbox/opencode-client.ts → packages/discord/src/sandbox/opencode/client.ts

@@ -1,7 +1,7 @@
 import { Context, Effect, Layer, ParseResult, Schema, Schedule } from "effect"
 import { HttpBody, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "@effect/platform"
-import { HealthCheckError, OpenCodeClientError } from "../errors"
-import { PreviewAccess, SessionId } from "../types"
+import { classifyOpenCodeFailure, HealthCheckError, OpenCodeClientError } from "../../errors"
+import { PreviewAccess, SessionId } from "../../types"
 
 const HealthResponse = Schema.Struct({
   healthy: Schema.Boolean,
@@ -108,6 +108,14 @@ export class OpenCodeClient extends Context.Tag("@discord/OpenCodeClient")<OpenC
       }
 
       /** Map HttpClientError + ParseError to OpenCodeClientError for a given operation. */
+      const openCodeError = (operation: string, statusCode: number, body: string) =>
+        OpenCodeClientError.make({
+          operation,
+          statusCode,
+          body,
+          kind: classifyOpenCodeFailure(statusCode, body),
+        })
+
       const mapErrors = <A, R>(
         operation: string,
         effect: Effect.Effect<A, HttpClientError.HttpClientError | ParseResult.ParseError, R>,
@@ -115,11 +123,11 @@ export class OpenCodeClient extends Context.Tag("@discord/OpenCodeClient")<OpenC
         effect.pipe(
           Effect.catchTags({
             ResponseError: (err) =>
-              new OpenCodeClientError({ operation, statusCode: err.response.status, body: err.message }),
+              openCodeError(operation, err.response.status, err.message),
             RequestError: (err) =>
-              new OpenCodeClientError({ operation, statusCode: 0, body: err.message }),
+              openCodeError(operation, 0, err.message),
             ParseError: (err) =>
-              new OpenCodeClientError({ operation, statusCode: 0, body: `Decode: ${err.message}` }),
+              openCodeError(operation, 0, `Decode: ${err.message}`),
           }),
         )
 
@@ -172,10 +180,10 @@ export class OpenCodeClient extends Context.Tag("@discord/OpenCodeClient")<OpenC
             Effect.catchTag("ResponseError", (err) =>
               err.response.status === 404
                 ? Effect.succeed(false)
-                : new OpenCodeClientError({ operation: "sessionExists", statusCode: err.response.status, body: err.message }),
+                : openCodeError("sessionExists", err.response.status, err.message),
             ),
             Effect.catchTag("RequestError", (err) =>
-              new OpenCodeClientError({ operation: "sessionExists", statusCode: 0, body: err.message }),
+              openCodeError("sessionExists", 0, err.message),
             ),
           )
 

+ 0 - 124
packages/discord/src/sandbox/pool.test.ts

@@ -1,124 +0,0 @@
-import { describe, expect } from "bun:test"
-import { Effect, Layer, Option } from "effect"
-import { OpenCodeClientError } from "../errors"
-import { SessionStore } from "../sessions/store"
-import { effectTest, testConfigLayer } from "../test/effect"
-import { ChannelId, GuildId, SandboxId, SessionId, SessionInfo, ThreadId } from "../types"
-import { OpenCodeClient } from "./opencode-client"
-import { ResumeFailed, SandboxProvisioner } from "./provisioner"
-import { ThreadAgentPool } from "./pool"
-
-const threadId = ThreadId.make("t1")
-const channelId = ChannelId.make("c1")
-const guildId = GuildId.make("g1")
-
-const session = SessionInfo.make({
-  threadId,
-  channelId,
-  guildId,
-  sandboxId: SandboxId.make("sb1"),
-  sessionId: SessionId.make("s1"),
-  previewUrl: "https://preview.example",
-  previewToken: null,
-  status: "active",
-  lastError: null,
-  resumeFailCount: 0,
-})
-
-const store = () => {
-  let row = Option.some(session)
-  return SessionStore.of({
-    upsert: (next) =>
-      Effect.sync(() => {
-        row = Option.some(next)
-      }),
-    getByThread: () => Effect.succeed(row),
-    hasTrackedThread: () => Effect.succeed(Option.isSome(row)),
-    getActive: () =>
-      Option.isSome(row) && row.value.status === "active"
-        ? Effect.succeed(row)
-        : Effect.succeed(Option.none()),
-    markActivity: () => Effect.void,
-    markHealthOk: () => Effect.void,
-    updateStatus: (_threadId, status) =>
-      Effect.sync(() => {
-        if (Option.isNone(row)) return
-        row = Option.some(row.value.withStatus(status))
-      }),
-    incrementResumeFailure: () => Effect.void,
-    listActive: () =>
-      Option.isSome(row) && row.value.status === "active"
-        ? Effect.succeed([row.value] as const)
-        : Effect.succeed([] as const),
-    listTrackedThreads: () =>
-      Option.isSome(row) && row.value.status !== "destroyed"
-        ? Effect.succeed([row.value.threadId] as const)
-        : Effect.succeed([] as const),
-    listStaleActive: () => Effect.succeed([] as const),
-    listExpiredPaused: () => Effect.succeed([] as const),
-  })
-}
-
-const provisioner = SandboxProvisioner.of({
-  provision: () => Effect.succeed(session),
-  resume: () => Effect.succeed(ResumeFailed.make({ allowRecreate: true })),
-  ensureActive: ({ current }) => Effect.succeed(Option.isSome(current) ? current.value : session),
-  ensureHealthy: () => Effect.succeed(true),
-  recoverSendFailure: (_threadId, next) => Effect.succeed(next.withStatus("paused")),
-  pause: (_threadId, next) => Effect.succeed(next.withStatus("paused")),
-  destroy: (_threadId, next) => Effect.succeed(next.withStatus("destroyed")),
-})
-
-const client = (statusCode: number, body: string) =>
-  OpenCodeClient.of({
-    waitForHealthy: () => Effect.succeed(true),
-    createSession: () => Effect.succeed(SessionId.make("s2")),
-    sessionExists: () => Effect.succeed(true),
-    listSessions: () => Effect.succeed([]),
-    sendPrompt: () => Effect.fail(new OpenCodeClientError({ operation: "sendPrompt", statusCode, body })),
-    abortSession: () => Effect.void,
-  })
-
-const withPool = <A, E, R>(
-  statusCode: number,
-  body: string,
-  run: Effect.Effect<A, E, R>,
-) => {
-  const deps = Layer.mergeAll(
-    testConfigLayer,
-    Layer.succeed(SessionStore, store()),
-    Layer.succeed(SandboxProvisioner, provisioner),
-    Layer.succeed(OpenCodeClient, client(statusCode, body)),
-  )
-  return run.pipe(
-    Effect.provide(ThreadAgentPool.layer.pipe(Layer.provide(deps))),
-  )
-}
-
-describe("ThreadAgentPool", () => {
-  effectTest("maps recoverable send failures to SandboxDeadError", () =>
-    withPool(
-      502,
-      "bad gateway",
-      Effect.gen(function* () {
-        const pool = yield* ThreadAgentPool
-        const agent = yield* pool.getOrCreate(threadId, channelId, guildId)
-        const error = yield* agent.send("hello").pipe(Effect.flip)
-        expect(error._tag).toBe("SandboxDeadError")
-      }),
-    ),
-  )
-
-  effectTest("keeps non-recoverable send failures as OpenCodeClientError", () =>
-    withPool(
-      400,
-      "bad request",
-      Effect.gen(function* () {
-        const pool = yield* ThreadAgentPool
-        const agent = yield* pool.getOrCreate(threadId, channelId, guildId)
-        const error = yield* agent.send("hello").pipe(Effect.flip)
-        expect(error._tag).toBe("OpenCodeClientError")
-      }),
-    ),
-  )
-})

+ 0 - 305
packages/discord/src/sandbox/pool.ts

@@ -1,305 +0,0 @@
-import { Context, Duration, Effect, Layer, Option, Ref, Schedule } from "effect"
-import { AppConfig } from "../config"
-import {
-  type ConfigEncodeError,
-  DatabaseError,
-  type HealthCheckError,
-  type OpenCodeClientError,
-  type SandboxCreateError,
-  type SandboxExecError,
-  SandboxDeadError,
-  type SandboxNotFoundError,
-  type SandboxStartError,
-} from "../errors"
-import { ActorMap } from "../lib/actors/keyed"
-import { logIgnore } from "../lib/log"
-import { SessionStore } from "../sessions/store"
-import { ChannelId, GuildId, PreviewAccess, SessionInfo, ThreadId } from "../types"
-import { OpenCodeClient } from "./opencode-client"
-import { SandboxProvisioner } from "./provisioner"
-
-/** Per-thread handle returned by ThreadAgentPool.resolve. */
-export interface ThreadAgent {
-  readonly threadId: ThreadId
-  /** Snapshot from when this agent handle was created. Prefer `current()` for live state. */
-  readonly session: SessionInfo
-  readonly current: () => Effect.Effect<SessionInfo, DatabaseError>
-  readonly send: (text: string) => Effect.Effect<string, OpenCodeClientError | SandboxDeadError | DatabaseError>
-  readonly pause: (reason?: string) => Effect.Effect<void, DatabaseError>
-  readonly destroy: () => Effect.Effect<void, DatabaseError>
-}
-
-type Runtime = {
-  readonly current: () => Effect.Effect<SessionInfo, DatabaseError>
-  readonly ensure: (
-    channelId: ChannelId,
-    guildId: GuildId,
-  ) => Effect.Effect<
-    SessionInfo,
-    | SandboxCreateError
-    | SandboxExecError
-    | SandboxNotFoundError
-    | SandboxStartError
-    | HealthCheckError
-    | OpenCodeClientError
-    | ConfigEncodeError
-    | SandboxDeadError
-    | DatabaseError
-  >
-  readonly send: (text: string) => Effect.Effect<string, OpenCodeClientError | SandboxDeadError | DatabaseError>
-  readonly pause: (reason: string) => Effect.Effect<void, DatabaseError>
-  readonly destroy: (reason: string) => Effect.Effect<void, DatabaseError>
-}
-
-export declare namespace ThreadAgentPool {
-  export interface Service {
-    /** Get an existing healthy ThreadAgent or create one. */
-    readonly getOrCreate: (
-      threadId: ThreadId,
-      channelId: ChannelId,
-      guildId: GuildId,
-    ) => Effect.Effect<
-      ThreadAgent,
-      | SandboxCreateError
-      | SandboxExecError
-      | SandboxNotFoundError
-      | SandboxStartError
-      | HealthCheckError
-      | OpenCodeClientError
-      | ConfigEncodeError
-      | SandboxDeadError
-      | DatabaseError
-    >
-    readonly hasTrackedThread: (threadId: ThreadId) => Effect.Effect<boolean, DatabaseError>
-    readonly getTrackedSession: (threadId: ThreadId) => Effect.Effect<Option.Option<SessionInfo>, DatabaseError>
-    readonly getActiveSessionCount: () => Effect.Effect<number, DatabaseError>
-    readonly pauseSession: (threadId: ThreadId, reason?: string) => Effect.Effect<void, DatabaseError>
-    readonly destroySession: (threadId: ThreadId) => Effect.Effect<void, DatabaseError>
-  }
-}
-
-export class ThreadAgentPool extends Context.Tag("@discord/ThreadAgentPool")<
-  ThreadAgentPool,
-  ThreadAgentPool.Service
->() {
-  static readonly layer = Layer.scoped(
-    ThreadAgentPool,
-    Effect.gen(function* () {
-      const config = yield* AppConfig
-      const provisioner = yield* SandboxProvisioner
-      const oc = yield* OpenCodeClient
-      const store = yield* SessionStore
-
-      const runtime = (
-        threadId: ThreadId,
-        state: Ref.Ref<Option.Option<SessionInfo>>,
-      ): Runtime => {
-        const dead = (error: OpenCodeClientError) => {
-          if (error.statusCode === 404) return true
-          if (error.statusCode === 0 || error.statusCode >= 500) return true
-          const body = error.body.toLowerCase()
-          if (body.includes("sandbox not found")) return true
-          if (body.includes("is the sandbox started")) return true
-          return false
-        }
-
-        const lookup = Effect.fnUntraced(function* () {
-          const loaded = yield* Ref.get(state)
-          if (Option.isSome(loaded)) return loaded
-          return Option.none<SessionInfo>()
-        })
-
-        const current = Effect.fnUntraced(function* () {
-          const session = yield* lookup()
-          if (Option.isSome(session)) return session.value
-          return yield* new DatabaseError({
-            cause: new Error(`missing session for thread ${threadId}`),
-          })
-        })
-
-        const ensure = (channelId: ChannelId, guildId: GuildId) =>
-          Effect.gen(function* () {
-            const next = yield* provisioner.ensureActive({
-              threadId,
-              channelId,
-              guildId,
-              current: yield* Ref.get(state),
-            })
-            yield* Ref.set(state, Option.some(next))
-            return next
-          })
-
-        const send = (text: string) =>
-          Effect.gen(function* () {
-            yield* store.markActivity(threadId)
-            const session = yield* current()
-            return yield* oc.sendPrompt(PreviewAccess.from(session), session.sessionId, text).pipe(
-              Effect.catchTag("OpenCodeClientError", (error) =>
-                provisioner.recoverSendFailure(threadId, session, error).pipe(
-                  Effect.flatMap((next) =>
-                    Ref.set(state, Option.some(next)),
-                  ),
-                  Effect.flatMap(() => {
-                    const failure: OpenCodeClientError | SandboxDeadError = dead(error)
-                      ? new SandboxDeadError({
-                        threadId,
-                        reason: `OpenCode send failed (${error.statusCode})`,
-                      })
-                      : error
-                    return Effect.fail(failure)
-                  }),
-                ),
-              ),
-            )
-          })
-
-        const pause = (reason: string) =>
-          Effect.gen(function* () {
-            const session = yield* lookup()
-            if (Option.isNone(session)) return
-            const next = yield* provisioner.pause(threadId, session.value, reason)
-            yield* Ref.set(state, Option.some(next))
-          })
-
-        const destroy = (reason: string) =>
-          Effect.gen(function* () {
-            const session = yield* lookup()
-            if (Option.isNone(session)) return
-            const next = yield* provisioner.destroy(threadId, session.value, reason)
-            yield* Ref.set(state, Option.some(next))
-          })
-
-        return { current, ensure, send, pause, destroy }
-      }
-
-      const actors: ActorMap.ActorMap<ThreadId, SessionInfo> = yield* ActorMap.make<ThreadId, SessionInfo>({
-        idleTimeout: config.sandboxTimeout,
-        onIdle: (threadId) =>
-          logIgnore(
-            runRuntime(
-              threadId,
-              (rt) => rt.pause("inactivity-timeout"),
-              { touch: false },
-            ).pipe(
-              Effect.tap(() => actors.remove(threadId)),
-            ),
-            "idle-pause",
-          ),
-        load: (threadId) => store.getByThread(threadId).pipe(Effect.catchAll(() => Effect.succeed(Option.none()))),
-        save: (_threadId, session) => logIgnore(store.upsert(session), "save-session").pipe(Effect.asVoid),
-      })
-
-      const runRuntime = <A, E>(
-        threadId: ThreadId,
-        f: (rt: Runtime) => Effect.Effect<A, E>,
-        options?: { touch?: boolean },
-      ) =>
-        actors.run(
-          threadId,
-          (state) => f(runtime(threadId, state)),
-          options,
-        )
-
-      const pauseNow = (threadId: ThreadId, reason: string) =>
-        runRuntime(threadId, (rt) => rt.pause(reason), { touch: false }).pipe(
-          Effect.tap(() => actors.remove(threadId)),
-        )
-
-      const destroyNow = (threadId: ThreadId, reason: string) =>
-        runRuntime(threadId, (rt) => rt.destroy(reason), { touch: false }).pipe(
-          Effect.tap(() => actors.remove(threadId)),
-        )
-
-      const makeAgent = (threadId: ThreadId, session: SessionInfo): ThreadAgent => ({
-        threadId,
-        session,
-        current: () =>
-          runRuntime(threadId, (rt) => rt.current(), { touch: false }),
-        send: (text: string) =>
-          runRuntime(threadId, (rt) => rt.send(text)),
-        pause: (reason = "manual") => pauseNow(threadId, reason),
-        destroy: () => destroyNow(threadId, "agent-destroy"),
-      })
-
-      const getOrCreate = Effect.fn("ThreadAgentPool.getOrCreate")(function* (
-        threadId: ThreadId,
-        channelId: ChannelId,
-        guildId: GuildId,
-      ) {
-        const session = yield* runRuntime(
-          threadId,
-          (rt) => rt.ensure(channelId, guildId),
-        )
-        return makeAgent(threadId, session)
-      })
-
-      const hasTrackedThread = Effect.fn("ThreadAgentPool.hasTrackedThread")(function* (threadId: ThreadId) {
-        return yield* store.hasTrackedThread(threadId)
-      })
-
-      const getTrackedSession = Effect.fn("ThreadAgentPool.getTrackedSession")(function* (threadId: ThreadId) {
-        return yield* store.getByThread(threadId)
-      })
-
-      const getActiveSessionCount = Effect.fn("ThreadAgentPool.getActiveSessionCount")(function* () {
-        return (yield* store.listActive()).length
-      })
-
-      const pauseSession = Effect.fn("ThreadAgentPool.pauseSession")(function* (
-        threadId: ThreadId,
-        reason = "manual",
-      ) {
-        yield* pauseNow(threadId, reason)
-      })
-
-      const destroySession = Effect.fn("ThreadAgentPool.destroySession")(function* (threadId: ThreadId) {
-        yield* destroyNow(threadId, "manual-destroy")
-      })
-
-      const cleanupPass = Effect.fnUntraced(function* () {
-        const stale = yield* store.listStaleActive(
-          Math.ceil(Duration.toMinutes(config.sandboxTimeout)) + config.staleActiveGraceMinutes,
-        )
-        yield* Effect.forEach(
-          stale,
-          (row) =>
-            logIgnore(
-              pauseNow(row.threadId, "cleanup-stale-active"),
-              "cleanup-pause",
-            ),
-          { concurrency: "unbounded", discard: true },
-        )
-
-        const expired = yield* store.listExpiredPaused(config.pausedTtlMinutes)
-        yield* Effect.forEach(
-          expired,
-          (row) =>
-            logIgnore(
-              destroyNow(row.threadId, "cleanup-expired-paused"),
-              "cleanup-destroy",
-            ),
-          { concurrency: "unbounded", discard: true },
-        )
-      })
-
-      yield* cleanupPass().pipe(
-        Effect.catchAll((error) =>
-          Effect.logError("Cleanup loop failed").pipe(
-            Effect.annotateLogs({ event: "cleanup.loop.failed", error: String(error) }),
-          ),
-        ),
-        Effect.repeat(Schedule.spaced(config.cleanupInterval)),
-        Effect.forkScoped,
-      )
-
-      return ThreadAgentPool.of({
-        getOrCreate,
-        hasTrackedThread,
-        getTrackedSession,
-        getActiveSessionCount,
-        pauseSession,
-        destroySession,
-      })
-    }),
-  )
-}

+ 4 - 12
packages/discord/src/sandbox/provisioner.ts

@@ -12,10 +12,10 @@ import {
   type SandboxNotFoundError,
   type SandboxStartError,
 } from "../errors"
-import { SessionStore } from "../sessions/store"
+import { SessionStore } from "../session/store"
 import { ChannelId, GuildId, PreviewAccess, SandboxId, SessionInfo, ThreadId } from "../types"
-import { DaytonaService, type SandboxHandle } from "./daytona"
-import { OpenCodeClient, OpenCodeSessionSummary } from "./opencode-client"
+import { DaytonaService, type SandboxHandle } from "./daytona/service"
+import { OpenCodeClient, OpenCodeSessionSummary } from "./opencode/client"
 
 import { logIgnore } from "../lib/log"
 
@@ -51,7 +51,6 @@ export class ResumeFailed extends Schema.Class<ResumeFailed>("ResumeFailed")({
 }) {}
 
 export type ResumeResult = Resumed | ResumeFailed
-type SendFailure = "session-missing" | "sandbox-down" | "non-recoverable"
 
 export declare namespace SandboxProvisioner {
   export interface Service {
@@ -179,13 +178,6 @@ export class SandboxProvisioner extends Context.Tag("@discord/SandboxProvisioner
 
       const restartOpenCodeServe =
         'pkill -f \'opencode serve --port 4096\' >/dev/null 2>&1 || true; for d in "$HOME/opencode" "/home/daytona/opencode" "/root/opencode"; do if [ -d "$d" ]; then cd "$d" && setsid opencode serve --port 4096 --hostname 0.0.0.0 > /tmp/opencode.log 2>&1 & exit 0; fi; done; exit 1'
-      const classifySendError = (error: OpenCodeClientError): SendFailure => {
-        if (error.statusCode === 404) return "session-missing"
-        if (error.statusCode === 0 || error.statusCode >= 500) return "sandbox-down"
-        const body = error.body.toLowerCase()
-        if (body.includes("sandbox not found") || body.includes("is the sandbox started")) return "sandbox-down"
-        return "non-recoverable"
-      }
 
       const provision = Effect.fn("SandboxProvisioner.provision")(function* (
         threadId: ThreadId,
@@ -452,7 +444,7 @@ export class SandboxProvisioner extends Context.Tag("@discord/SandboxProvisioner
         session: SessionInfo,
         error: OpenCodeClientError,
       ) {
-        const kind = classifySendError(error)
+        const kind = error.kind
         if (kind === "non-recoverable") return session
 
         yield* store.incrementResumeFailure(threadId, String(error))

+ 1 - 0
packages/discord/src/sessions/store.test.ts → packages/discord/src/session/store.test.ts

@@ -17,6 +17,7 @@ const makeConfig = (databasePath: string) =>
     discordCommandGuildId: "",
     databasePath,
     daytonaApiKey: Redacted.make("daytona"),
+    daytonaSnapshot: "",
     openCodeZenApiKey: Redacted.make("zen"),
     githubToken: "",
     logLevel: "info",

+ 0 - 0
packages/discord/src/sessions/store.ts → packages/discord/src/session/store.ts


+ 1 - 0
packages/discord/src/test/effect.ts

@@ -44,6 +44,7 @@ export const testConfigLayer = Layer.succeed(
     discordCommandGuildId: "",
     databasePath: ":memory:",
     daytonaApiKey: Redacted.make("test"),
+    daytonaSnapshot: "",
     openCodeZenApiKey: Redacted.make("test"),
     githubToken: "",
     logLevel: "info" as const,