diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index a3a55014ba..c5d668e9e5 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -12,9 +12,10 @@ flowchart LR
Bridge --> Contracts["shared/contracts routes + events"]
Contracts --> Routes["src/main/routes dispatcher"]
Routes --> SessionOwners["explicit session owners
search / translation / export / usage / catalog"]
- Routes --> Ports["core session/chat services + narrow ports"]
- Ports --> SessionFacade["AgentSessionPresenter
core session façade"]
- SessionFacade --> Manager["AgentManager
descriptor.kind router"]
+ Routes --> Services["SessionService / ChatService"]
+ Services --> Coordinators["Lifecycle / Turn / Assignment / Projection"]
+ Compat["AgentSessionPresenter
compatibility forwarding"] --> Coordinators
+ Coordinators --> Manager["AgentManager
descriptor.kind router"]
Manager --> DeepBackend["typed DeepChat backend"]
Manager --> AcpBackend["direct ACP backend"]
DeepBackend --> DeepRuntime["DeepChatAgentRuntime"]
@@ -35,9 +36,13 @@ flowchart LR
- `kind=acp` 使用 direct ACP backend 和外部 ACP protocol loop,不进入 `DeepChatLoopEngine`。
- `kind=deepchat + providerId=acp` 仍是受支持的兼容组合:session 走 DeepChat backend/loop,provider
选择才进入 `AcpProvider` adapter。
-- `AgentSessionPresenter` 是 core session façade,保留 session CRUD、title、turn、transfer/subagent 与
- shared projection 编排。history、translation、export、usage、RTK、catalog 和 startup migrations 由
- typed routes/lifecycle hooks 直接组合各自 owner。
+- 四个 `sessionApplication` coordinator 拥有 core session lifecycle、turn、assignment 和 projection;
+ Session/Chat routes、Remote 和 Cron 通过 consumer-owned narrow ports 使用同一组实例。
+- `AgentSessionPresenter` 仅保留 compatibility forwarding,把兼容调用转发到同一组 coordinator;不再拥有
+ core session policy/state,也不再拥有 history、translation、export、usage、RTK、catalog 或 startup
+ migration behavior。
+- history、translation、export、usage、RTK、catalog 和 startup migrations 由 typed routes/lifecycle
+ hooks 直接组合各自 owner。
- `AgentRuntimePresenter` 仍初始化 `DeepChatAgentRuntime`,并保留 DeepChat state/delegate、message、Tape、
prompt/tool/provider adapter wiring;它不再实现 unified agent interface,也不负责 ACP runtime 构造。
@@ -55,7 +60,8 @@ flowchart LR
| DeepChat loop | `src/main/agent/deepchat/loop/` | `LoopRun`、provider/tool round state machine、fixed awaited commits与窄 ports |
| DeepChat Memory adapter | `src/main/agent/deepchat/memory/` | sole runtime coordinator、prompt contributor、background ingestion observer |
| ACP runtime | `src/main/agent/acp/` | catalog、launch、client/process/session/protocol、direct instance/runtime |
-| `AgentSessionPresenter` | `src/main/presenter/agentSessionPresenter/` | core session lifecycle/turn/assignment façade与 shared projection operations |
+| session application | `src/main/presenter/sessionApplication/` | Lifecycle、Turn、AgentAssignment、Projection coordinators 与窄 dependency ports |
+| `AgentSessionPresenter` | `src/main/presenter/agentSessionPresenter/` | core session public compatibility forwarding;不拥有 application behavior |
| session boundary owners | `src/main/routes/sessions/`, `src/main/presenter/exporter/agentSessionExporter.ts`, `src/main/presenter/usageStatsService.ts` | history、translation、current export、usage dashboard/backfill |
| startup maintenance | `src/main/presenter/startupMigrations/` | default legacy import and stateless session-data migrations |
| shared session policies | `src/main/agent/shared/` | available-agent catalog and assistant-model selection |
@@ -63,8 +69,8 @@ flowchart LR
| `ToolPresenter` | `src/main/presenter/toolPresenter/` | MCP/local tool 聚合、collision policy、权限预检查、调用路由 |
| `MemoryPresenter` | `src/main/presenter/memoryPresenter/` | Memory rows、retrieval、write、vector、maintenance kernel |
| `LLMProviderPresenter` | `src/main/presenter/llmProviderPresenter/` | provider/model runtime和 DeepChat ACP-provider compatibility adapter |
-| `RemoteControlPresenter` | `src/main/presenter/remoteControlPresenter/` | remote channel control,generation control 走 manager port |
-| `CronJobsService` | `src/main/presenter/cronJobs/` | detached session run、cron 调度和 Remote 投递 |
+| `RemoteControlPresenter` | `src/main/presenter/remoteControlPresenter/` | remote channel control;session 操作走四个 narrow ports,generation control 走 manager port |
+| `CronJobsService` | `src/main/presenter/cronJobs/` | detached session run、composition-owned starter、cron 调度和 Remote 投递 |
## Agent runtime 分层
@@ -132,6 +138,8 @@ fresh resume run。外部 hook notifications 仍是 non-blocking observer。
- 同一 guard 保持 Memory unique owner/structure、causal observation read-only 和 renderer typed boundary。
- 同一 guard 阻止 removed session-boundary methods/interface declarations、foreign owner imports,以及五个
startup hook 中的 presenter dependency、unsafe cast 和 optional task probe 回流。
+- 同一 guard 阻止 Session/Chat、Remote、Cron 等 migrated consumer 重新依赖 session presenter、重复构造
+ coordinator、coordinator 反向导入 session-boundary owner,以及引入 combined session application façade。
- `scripts/agent-cleanup-guard.mjs` 覆盖 `src/main/agent/**` 与 retained presenter/tool/skill hot paths,防止旧
agent/session presenter import 回流。
diff --git a/docs/FLOWS.md b/docs/FLOWS.md
index 613b5f848c..cfa6279f05 100644
--- a/docs/FLOWS.md
+++ b/docs/FLOWS.md
@@ -10,18 +10,18 @@ sequenceDiagram
participant R as Renderer
participant C as SessionClient/ChatClient
participant Route as src/main/routes
- participant F as AgentSessionPresenter
+ participant App as Lifecycle / Turn / Assignment / Projection
participant S as AppSessionService
participant M as AgentManager
participant B as Typed Backend
R->>C: create/send/restore
C->>Route: window.deepchat.invoke(route)
- Route->>F: createSession/restore/send/listMessagesPage
- F->>S: create/bind/read app-session shell
- F->>M: resolve executable descriptor/session handle
+ Route->>App: narrow lifecycle/turn/projection port
+ App->>S: create/bind/read app-session shell
+ App->>M: resolve executable descriptor/session handle
M->>B: switch descriptor.kind and open handle
- F->>B: initialize/send/snapshot
+ App->>B: initialize/send/snapshot
B-->>R: existing message projection + chat.stream.* events
```
@@ -35,11 +35,14 @@ sequenceDiagram
- `src/main/agent/manager/agentManager.ts`
- `src/main/agent/manager/deepChatAgentBackend.ts`
- `src/main/agent/manager/directAcpAgentBackend.ts`
+- `src/main/presenter/sessionApplication/`
- `src/main/presenter/agentSessionPresenter/index.ts`
-`AgentSessionPresenter` 是 core session lifecycle/turn/assignment façade;agent kind resolution 和
-executable backend selection 只发生在 `AgentManager`。`new_sessions.session_kind` 仍表示
-`regular | subagent`,不决定 DeepChat/ACP backend。
+`SessionService` / `ChatService` 直接使用 consumer-owned coordinator ports。`AgentSessionPresenter` 只为
+兼容调用转发 core session methods;history、translation、export、usage、RTK、catalog 与 startup
+maintenance 直接进入各自 owner,不经过该 presenter。agent kind resolution 和 executable backend
+selection 只发生在 `AgentManager`。`new_sessions.session_kind` 仍表示 `regular | subagent`,不决定
+DeepChat/ACP backend。
## 2. DeepChat 消息处理主循环
@@ -124,13 +127,13 @@ sequenceDiagram
participant R as Renderer messageStore
participant S as SessionClient
participant Route as SessionService
- participant N as AgentSessionPresenter
+ participant P as SessionProjectionCoordinator
participant DB as DeepChatMessageStore
R->>S: restore(sessionId, limit=100)
S->>Route: sessions.restore
- Route->>N: restoreSession
- N->>DB: listPageBySession
+ Route->>P: getSession + listMessagesPage
+ P->>DB: listPageBySession
DB-->>R: latest page + nextCursor
R->>S: listMessagesPage(cursor)
S->>Route: sessions.listMessagesPage
@@ -151,7 +154,8 @@ sequenceDiagram
```mermaid
flowchart TD
- Route["AgentSessionPresenter"] --> Manager["AgentManager"]
+ CompatFacade["AgentSessionPresenter
compatibility forwarding"] --> App["Session application coordinator"]
+ App --> Manager["AgentManager"]
Manager --> Kind{"descriptor.kind"}
Kind -->|acp| Direct["DirectAcpSessionBackend"]
Direct --> AcpRuntime["AcpAgentRuntime"]
@@ -233,20 +237,23 @@ sequenceDiagram
participant Client as CronJobsClient
participant Service as CronJobsService
participant Utility as Scheduler utility
- participant Agent as AgentSessionPresenter
+ participant Starter as Cron session starter
+ participant App as Lifecycle / Turn
+ participant Runtime as Agent runtime updates
participant Remote as RemoteControlPresenter
UI->>Client: list/upsert/toggle/runNow
Client->>Service: cronJobs.* route
Service->>Utility: reconcile enabled jobs
Utility->>Service: RUN_DUE
- Service->>Agent: create detached session and send task prompt
- Agent-->>Service: run status and output updates
+ Service->>Starter: start run
+ Starter->>App: create detached session + send task prompt
+ Runtime-->>Service: DeepChatInternalSessionUpdate status/output/completion
Service->>Remote: optional notification-only delivery
```
Triggers 使用 cron 表达式。每次触发创建独立 detached session;Remote 投递只发送通知,不进入普通
-Remote 会话上下文。
+Remote 会话上下文。starter 在 composition root 接线,不依赖 route runtime 初始化。
## 10. Remote Control
@@ -259,7 +266,8 @@ flowchart LR
WeChat["WeChat iLink"] --> Remote
Remote --> Auth["channel auth / binding store"]
Remote --> Runner["remote conversation runner"]
- Runner --> Agent["AgentSessionPresenter"]
+ Runner --> Ports["Lifecycle / Turn / Assignment / Projection ports"]
+ Runner --> Generation["AgentManager generation port"]
```
统一远程控制支持绑定、默认 agent、默认 workdir、`/sessions`、`/model`、状态输出、媒体/Markdown
diff --git a/docs/README.md b/docs/README.md
index 4b5aa9b44e..4a7b8156cc 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -12,8 +12,8 @@ Renderer
-> window.deepchat
-> shared/contracts/routes + shared/contracts/events
-> src/main/routes dispatcher
- -> route services / presenter-backed ports
- -> agentSessionPresenter / agentRuntimePresenter / toolPresenter / llmProviderPresenter
+ -> route services / consumer-owned narrow ports
+ -> sessionApplication coordinators / typed runtimes / retained resource presenters
```
`useLegacyPresenter()`、`presenter:call`、`remoteControlPresenter:call` 和
diff --git a/docs/architecture/agent-system-layered-runtime/README.md b/docs/architecture/agent-system-layered-runtime/README.md
index 6305e77757..943399e811 100644
--- a/docs/architecture/agent-system-layered-runtime/README.md
+++ b/docs/architecture/agent-system-layered-runtime/README.md
@@ -39,8 +39,9 @@ DeepChat loop + `AcpProvider` compatibility。
Memory runtime orchestration 已收敛到唯一 `MemoryRuntimeCoordinator`,通过 awaited
`MemoryPromptContributor` 与 background `MemoryIngestionObserver` 接入;Tape tool facts 已迁到 stable
per-fact `TapeRecorder` path,causal observation 只读联结现有 Tape/message/trace。`AgentSessionPresenter`
-只保留 core session lifecycle/turn/assignment/shared projection façade;typed routes 直接组合 history、
-translation、export、usage、RTK 与 catalog owner,startup hooks 直接调用 migration/maintenance owner。
+仅保留 compatibility forwarding;core lifecycle、turn、assignment 与 projection 已由四个
+composition-owned session application coordinators 承担。typed routes 直接组合 history、translation、
+export、usage、RTK 与 catalog owner,startup hooks 直接调用 migration/maintenance owner。
`AgentRuntimePresenter` 保留 DeepChat state/delegate 与 adapter wiring;两者不再构成 generic agent runtime。
current docs、architecture guards 与 baseline generator
已在 `ASLR-091` 收敛;`ASLR-092` 已完成 canonical baseline write、全量
@@ -169,15 +170,18 @@ accept input / claim pending item
## AFTER:已实现的当前架构
```text
-typed routes / remote / cron
- │
- ▼
- AgentManager (control plane)
- ├─ AgentCatalog
- │ ├─ DeepChatAgentRepository ─┐
- │ └─ AcpAgentRepository ├─ shared agents table + typed codecs
- ├─ AppSessionService ──────────┴─ new_sessions / transcript projection
- └─ explicit switch(agent.kind)
+typed routes -> SessionService / ChatService -- narrow ports ─┐
+remote -> RemoteConversationRunner -- four narrow ports ─────┤
+cron -> Cron session starter -- Lifecycle / Turn ports ──────┤
+AgentSessionPresenter compatibility façade -- forwarding ─────┘
+ ▼
+Lifecycle / Turn / AgentAssignment / Projection
+├─ AppSessionService ─ new_sessions / window binding / shared CRUD
+└─ AgentManager (control plane)
+ ├─ AgentCatalog
+ │ ├─ DeepChatAgentRepository ─┐
+ │ └─ AcpAgentRepository ├─ shared agents table + typed codecs
+ └─ explicit switch(agent.kind)
│
├─ kind=deepchat
│ ▼
@@ -270,7 +274,8 @@ src/main/agent/
└── pending/ # durable pending input coordination
src/main/presenter/
-├── agentSessionPresenter/ # retained route/application/shared-projection façade
+├── sessionApplication/ # lifecycle/turn/assignment/projection coordinators
+├── agentSessionPresenter/ # retained compatibility façade
└── agentRuntimePresenter/ # retained DeepChat state/delegate + message/Tape/resource adapters
```
@@ -407,6 +412,7 @@ renderer event 缺口;
| --- | --- |
| agent identity、kind、display summary | `AgentCatalog` |
| app session title/project/pin/draft/window binding | shared `AppSessionService` / `new_sessions` |
+| lifecycle transaction / turn commands / assignment policy / renderer projection | four `sessionApplication` coordinators over narrow owner ports |
| DeepChat effective config、status、pending inputs、ordered interactions | `DeepChatAgentInstance` |
| pre-stream cancellation before active generation registration | `DeepChatAgentInstance` preparation state |
| active run、abort signal、per-attempt requestSeq、outer providerRoundCount、round messages、overflow retry flags | per-turn `LoopRun` |
diff --git a/docs/architecture/agent-system.md b/docs/architecture/agent-system.md
index a894c89f39..c4ea5be8ce 100644
--- a/docs/architecture/agent-system.md
+++ b/docs/architecture/agent-system.md
@@ -32,9 +32,11 @@ kind=acp -> direct ACP backend -> external ACP protocol
```mermaid
flowchart TD
UI["Renderer / typed routes"] --> RouteOwners["Session route owners
search / translation / export / usage / catalog"]
- UI --> Facade["AgentSessionPresenter
core session façade"]
- Facade --> Sessions["AppSessionService"]
- Facade --> Manager["AgentManager"]
+ UI --> Services["SessionService / ChatService"]
+ Services --> App["Session application coordinators"]
+ Compat["AgentSessionPresenter
compatibility forwarding"] --> App
+ App --> Sessions["AppSessionService"]
+ App --> Manager["AgentManager"]
Manager --> Catalog["strict executable catalog"]
Manager --> DeepBackend["DeepChatAgentBackend"]
Manager --> AcpBackend["DirectAcpSessionBackend"]
@@ -53,8 +55,11 @@ flowchart TD
- `AgentManager` 是薄 control plane,只做 catalog/app-session lookup、alias normalization、kind switch 和
required facet selection。
-- `AgentSessionPresenter` 保留 session CRUD、draft/window binding、title、turn、transfer/subagent 及 shared
- projection application logic;它不猜 backend kind,也不拥有 import/search/export/dashboard/catalog。
+- `SessionLifecycleCoordinator`、`SessionTurnCoordinator`、`SessionAgentAssignmentCoordinator` 和
+ `SessionProjectionCoordinator` 分别拥有 core session application invariants;typed Session/Chat、Remote
+ 和 Cron 通过 consumer-owned narrow ports 调用它们。
+- `AgentSessionPresenter` 仅保留 compatibility public surface;core session methods 只转发到
+ composition-owned coordinators,不拥有 application policy、state 或 session-boundary capabilities。
- typed session routes 直接组合 `SessionHistorySearch`、`SessionTranslation`、
`AgentSessionExportService`、`UsageStatsService`、RTK runtime service 与 available-agent catalog policy;
lifecycle hooks 直接调用 startup migration/maintenance owner。
@@ -62,7 +67,7 @@ flowchart TD
message/Tape/prompt/provider/tool/permission adapters。它不再实现 unified agent interface,也不构造
`AcpAgentRuntime`。
- composition root 负责 backend wiring 和 `AcpAgentRuntime` construction;runtime/instance 实现分别位于
- `agent/deepchat` 与 `agent/acp`。
+ `agent/deepchat` 与 `agent/acp`,并且只构造一组 session application coordinators。
## 目录与职责
@@ -108,7 +113,8 @@ kind-specific facet。不存在 optional-method reflection,也不存在 agent
`legacy | direct runtimeKind`。
session delete 是 descriptor-independent cleanup:manager 不 hydrate、不读取 catalog,分别清理两个
-backend cache/durable binding;façade 再按 shared state、permission、skills、app row 的既有顺序清理。
+backend cache/durable binding;Lifecycle deletion transaction 再按 shared state、permission、skills、app
+row 的既有顺序清理。
## DeepChat instance 与 lifecycle
@@ -167,7 +173,7 @@ DeepChat app projection;`acp_turns` 只是 protocol metadata。
仍保留:
-- `AgentSessionPresenter` 和 `AgentRuntimePresenter` 两个薄而明确的 application/state-delegate façade;
+- `AgentSessionPresenter` compatibility façade 和 `AgentRuntimePresenter` state/delegate façade;
- `AcpProvider` 的 DeepChat + ACP-provider compatibility;
- `startupMigrations/LegacyChatImportService`、旧 conversations/messages、`SessionPresenter`
export/thread/data compatibility;current agent-session export 由 `AgentSessionExportService` 拥有;
@@ -186,14 +192,15 @@ DeepChat app projection;`acp_turns` 只是 protocol metadata。
按问题选择入口:
1. kind/session routing:`src/main/agent/manager/agentManager.ts`
-2. core session behavior:`src/main/presenter/agentSessionPresenter/index.ts`
-3. session search/translation:`src/main/routes/sessions/`
-4. current session export:`src/main/presenter/exporter/agentSessionExporter.ts`
-5. usage/startup/catalog:`src/main/presenter/{usageStatsService.ts,startupMigrations/}` 与
+2. core session application behavior:`src/main/presenter/sessionApplication/`
+3. compatibility forwarding:`src/main/presenter/agentSessionPresenter/index.ts`
+4. session search/translation:`src/main/routes/sessions/`
+5. current session export:`src/main/presenter/exporter/agentSessionExporter.ts`
+6. usage/startup/catalog:`src/main/presenter/{usageStatsService.ts,startupMigrations/}` 与
`src/main/agent/shared/availableAgentCatalog.ts`
-6. DeepChat session state:`src/main/agent/deepchat/instance/`
-7. provider/tool round:`src/main/agent/deepchat/loop/`,再看 retained presenter adapters
-8. direct ACP:`src/main/agent/acp/instance/` 与 `src/main/agent/acp/runtime/`
-9. tool source/dispatch:`src/main/presenter/toolPresenter/`
-10. Tape/message projection:`src/main/presenter/agentRuntimePresenter/{tapeService,messageStore}.ts`
-11. Memory runtime seam:`src/main/agent/deepchat/memory/memoryRuntimeCoordinator.ts`
+7. DeepChat session state:`src/main/agent/deepchat/instance/`
+8. provider/tool round:`src/main/agent/deepchat/loop/`,再看 retained presenter adapters
+9. direct ACP:`src/main/agent/acp/instance/` 与 `src/main/agent/acp/runtime/`
+10. tool source/dispatch:`src/main/presenter/toolPresenter/`
+11. Tape/message projection:`src/main/presenter/agentRuntimePresenter/{tapeService,messageStore}.ts`
+12. Memory runtime seam:`src/main/agent/deepchat/memory/memoryRuntimeCoordinator.ts`
diff --git a/docs/architecture/baselines/agent-system-layered-runtime-baseline.json b/docs/architecture/baselines/agent-system-layered-runtime-baseline.json
index eefb2b78b3..05a62c13a2 100644
--- a/docs/architecture/baselines/agent-system-layered-runtime-baseline.json
+++ b/docs/architecture/baselines/agent-system-layered-runtime-baseline.json
@@ -1,7 +1,7 @@
{
"schemaVersion": 2,
"goal": "agent-system-layered-runtime",
- "headCommit": "56fac76458c97ca0055417d8bcd20286be82d509",
+ "headCommit": "1122b240619afae1bd97c2395f0c820c41ecf048",
"relevantWorkingTree": {
"dirty": false,
"files": []
@@ -75,6 +75,7 @@
"src/main/agent/shared/agentRowStore.ts",
"src/main/agent/shared/agentSessionHandle.ts",
"src/main/agent/shared/agentSessionIds.ts",
+ "src/main/agent/shared/agentSessionNormalization.ts",
"src/main/agent/shared/agentSharedData.ts",
"src/main/agent/shared/appSessionService.ts",
"src/main/agent/shared/assistantModelSelection.ts",
@@ -304,7 +305,7 @@
"src/shared/contracts/routes/window.routes.ts",
"src/shared/contracts/routes/workspace.routes.ts"
],
- "sha256": "ab4fcf04f5091ca2f897def98f392ece99a85aea3e9a9f4a569be419a4caa9d9"
+ "sha256": "a7eef369f151c3679ba6f36e12041fed36fab124f5cd33a897c2c06c9925bcb8"
},
"storage": {
"sqlite": {
@@ -417,7 +418,7 @@
"memory_vector"
],
"versionContract": "embedding identity stored in embedding_meta; no numeric schema version",
- "sha256": "56d7d6a66e770ff389431935b67505199a7b8c01abc2fdf7be20f98bde8cf0c6"
+ "sha256": "fdcb17375f3c938763aff162c179f04bc98f0392feebdc0b30b1fd9f48cdc025"
}
},
"compositionAndShutdown": {
@@ -427,7 +428,7 @@
"src/main/presenter/lifecyclePresenter/hooks/beforeQuit/presenterDestroyHook.ts",
"src/main/presenter/lifecyclePresenter/index.ts"
],
- "sha256": "de762ce51ecfdecca2501bb24e6039900940d8775e16719c76369b2dd7eadd77"
+ "sha256": "90836b4d0ef4078293ac7d001ef2992f572809a10c67d35fdd8a972e8a66fb86"
},
"dependencyMetrics": {
"loopFiles": [
diff --git a/docs/architecture/baselines/dependency-report.md b/docs/architecture/baselines/dependency-report.md
index a00e12475e..22dc3f6b8b 100644
--- a/docs/architecture/baselines/dependency-report.md
+++ b/docs/architecture/baselines/dependency-report.md
@@ -4,13 +4,13 @@ Generated on 2026-07-13.
## main
-- Total files: 550
-- Internal dependency edges: 1488
+- Total files: 563
+- Internal dependency edges: 1526
- Cycles detected: 36
### Top outgoing dependencies
-- `presenter/index.ts`: 63
+- `presenter/index.ts`: 70
- `presenter/agentRuntimePresenter/index.ts`: 45
- `presenter/sqlitePresenter/index.ts`: 40
- `presenter/sqlitePresenter/schemaCatalog.ts`: 37
@@ -18,7 +18,7 @@ Generated on 2026-07-13.
- `presenter/configPresenter/index.ts`: 27
- `presenter/lifecyclePresenter/hooks/index.ts`: 23
- `presenter/toolPresenter/agentTools/agentToolManager.ts`: 22
-- `presenter/memoryPresenter/index.ts`: 19
+- `presenter/memoryPresenter/index.ts`: 20
- `presenter/llmProviderPresenter/index.ts`: 17
- `agent/acp/runtime/index.ts`: 15
- `presenter/filePresenter/mime.ts`: 14
@@ -29,20 +29,20 @@ Generated on 2026-07-13.
### Top incoming dependencies
- `presenter/index.ts`: 49
-- `routes/publishDeepchatEvent.ts`: 44
+- `routes/publishDeepchatEvent.ts`: 42
- `presenter/remoteControlPresenter/types.ts`: 38
- `presenter/sqlitePresenter/tables/baseTable.ts`: 38
+- `agent/shared/agentSessionIds.ts`: 31
- `events.ts`: 30
- `eventbus.ts`: 29
-- `agent/shared/agentSessionIds.ts`: 26
-- `presenter/memoryPresenter/types.ts`: 22
+- `presenter/memoryPresenter/types.ts`: 23
- `presenter/remoteControlPresenter/services/remoteBindingStore.ts`: 22
-- `presenter/sqlitePresenter/index.ts`: 22
+- `presenter/sqlitePresenter/index.ts`: 21
- `presenter/memoryPresenter/ports.ts`: 20
- `presenter/remoteControlPresenter/services/remoteConversationRunner.ts`: 16
+- `presenter/memoryPresenter/domain/types.ts`: 14
- `presenter/filePresenter/BaseFileAdapter.ts`: 13
- `presenter/memoryPresenter/context.ts`: 12
-- `presenter/sqlitePresenter/tables/deepchatTapeEntries.ts`: 12
### Cycle samples
diff --git a/docs/architecture/baselines/main-kernel-boundary-baseline.md b/docs/architecture/baselines/main-kernel-boundary-baseline.md
index fe4281422e..36bf710cdd 100644
--- a/docs/architecture/baselines/main-kernel-boundary-baseline.md
+++ b/docs/architecture/baselines/main-kernel-boundary-baseline.md
@@ -18,7 +18,7 @@ Current phase: P5.
| `renderer.quarantine.windowApi.count` | 0 |
| `renderer.quarantine.sourceFile.count` | 0 |
| `hotpath.presenterEdge.count` | 9 |
-| `runtime.rawTimer.count` | 205 |
+| `runtime.rawTimer.count` | 206 |
| `migrated.rawChannel.count` | 0 |
| `bridge.active.count` | 0 |
| `bridge.expired.count` | 0 |
@@ -87,7 +87,7 @@ Current phase: P5.
## Raw Timers
-- Total count: 205
+- Total count: 206
- `src/main/presenter/githubCopilotDeviceFlow.ts`: 6
- `src/main/presenter/browser/BrowserTab.ts`: 5
diff --git a/docs/architecture/baselines/main-kernel-migration-scoreboard.json b/docs/architecture/baselines/main-kernel-migration-scoreboard.json
index 59d3c6e4f7..237af9d2b2 100644
--- a/docs/architecture/baselines/main-kernel-migration-scoreboard.json
+++ b/docs/architecture/baselines/main-kernel-migration-scoreboard.json
@@ -14,7 +14,7 @@
"renderer.quarantine.windowApi.count": 0,
"renderer.quarantine.sourceFile.count": 0,
"hotpath.presenterEdge.count": 9,
- "runtime.rawTimer.count": 205,
+ "runtime.rawTimer.count": 206,
"migrated.rawChannel.count": 0,
"bridge.active.count": 0,
"bridge.expired.count": 0
diff --git a/docs/architecture/baselines/main-kernel-migration-scoreboard.md b/docs/architecture/baselines/main-kernel-migration-scoreboard.md
index eb7a043245..5e409c2de3 100644
--- a/docs/architecture/baselines/main-kernel-migration-scoreboard.md
+++ b/docs/architecture/baselines/main-kernel-migration-scoreboard.md
@@ -18,7 +18,7 @@ Phase 0 establishes the comparison baseline. Later phases should update this rep
| `renderer.quarantine.windowApi.count` | 0 | baseline |
| `renderer.quarantine.sourceFile.count` | 0 | baseline |
| `hotpath.presenterEdge.count` | 9 | baseline |
-| `runtime.rawTimer.count` | 205 | baseline |
+| `runtime.rawTimer.count` | 206 | baseline |
| `migrated.rawChannel.count` | 0 | baseline |
| `bridge.active.count` | 0 | baseline |
| `bridge.expired.count` | 0 | baseline |
diff --git a/docs/architecture/session-application-coordinators/plan.md b/docs/architecture/session-application-coordinators/plan.md
new file mode 100644
index 0000000000..058c94049f
--- /dev/null
+++ b/docs/architecture/session-application-coordinators/plan.md
@@ -0,0 +1,312 @@
+# Session Application Coordinators — Implementation Plan
+
+## Approach
+
+The migration uses a strangler sequence inside the existing compatibility façade:
+
+1. characterize transaction ordering and consumer behavior;
+2. define consumer-owned ports and coordinator-local dependencies;
+3. extract Projection first because it owns shared status/window/event state;
+4. extract Assignment and its focused policy seam;
+5. extract Turn, then Lifecycle over narrow Assignment/Turn/Projection ports;
+6. replace route, Remote, and Cron presenter dependencies;
+7. reduce the presenter to forwarding, add guards, and update current docs.
+
+The implementation was performed on `task/session-application-coordinators`, based directly on
+`dev@28e2a0e92`, without cherry-picking stage 1. After independent implementation and review, the
+branch merged `dev@135779210` and reconciled both ownership changes file by file.
+
+## Planned Module Shape
+
+```text
+src/main/presenter/sessionApplication/
+├── lifecycleCoordinator.ts
+├── turnCoordinator.ts
+├── agentAssignmentCoordinator.ts
+├── agentAssignmentPolicy.ts # focused pure/shared resolution, if needed
+├── projectionCoordinator.ts
+└── ports.ts # coordinator dependency ports only
+
+src/main/routes/
+├── sessions/sessionService.ts # consumer-owned lifecycle/projection ports
+├── chat/chatService.ts # consumer-owned turn/projection ports
+└── hotPathPorts.ts # provider-only adapters; no session presenter adapter
+
+src/main/presenter/
+├── agentSessionPresenter/index.ts # compatibility forwarding; stage-1 owners stay independent
+├── remoteControlPresenter/ # four explicit remote session ports
+└── cronJobs/ # starter factory over lifecycle + turn ports
+```
+
+Names may follow an existing local convention, but the ownership and dependency rules in the spec are
+fixed. Do not create `SessionApplicationCoordinator`, `SessionApplicationServices`, or another object
+that re-exports all four capabilities.
+
+## Module Contracts
+
+### Projection
+
+Projection is extracted first and constructed once. Its public surface is grouped by capability, not
+by transport:
+
+```ts
+interface SessionProjectionReadPort {
+ getSession(sessionId: string): Promise
+ listSessions(filters?: SessionListFilters): Promise
+ listLightweight(options?: SessionLightweightOptions): Promise
+ getLightweightByIds(sessionIds: string[]): Promise
+}
+
+interface SessionWindowProjectionPort {
+ activate(webContentsId: number, sessionId: string): Promise
+ deactivate(webContentsId: number): Promise
+ getActive(webContentsId: number): Promise
+ getActiveId(webContentsId: number): string | null
+}
+
+interface SessionProjectionMutationPort {
+ materialize(sessionId: string): Promise
+ notify(input: SessionProjectionUpdate): void
+ forgetStatus(sessionIds: string[]): void
+ scheduleTitleGeneration(input: TitleGenerationInput): void
+}
+```
+
+Message, Tape, trace, replay, rename, and pin operations remain concrete methods on the coordinator;
+consumers receive only the subsets they require. Full snapshot materialization continues through
+typed backend handles and may hydrate runtime state. Lightweight projection remains non-hydrating.
+
+### Assignment
+
+Assignment separates resolution policy from commands so Lifecycle can consume policy without a
+runtime construction cycle:
+
+```ts
+interface SessionAssignmentPolicyPort {
+ resolveCreateAssignment(input: CreateAssignmentInput): Promise
+ resolveSubagentAssignment(input: SubagentAssignmentInput): Promise
+ resolveTransferTarget(input: TransferTargetInput): Promise
+}
+```
+
+The policy may be a focused module created from the same config/catalog dependencies. The command
+coordinator owns transfer and setting mutations. A required lifecycle deletion port is injected into
+assignment commands through a concrete, initialized adapter; optional setters and late mutation of
+dependencies are forbidden.
+
+### Turn
+
+Turn consumes typed handle resolution, app-session mutation, assignment workdir/runtime-setting
+ports, transcript mutation, and projection mutation:
+
+```ts
+interface SessionTurnPort {
+ sendMessage(
+ sessionId: string,
+ content: string | SendMessageInput,
+ options?: { maxProviderRounds?: number }
+ ): Promise
+ steerActiveTurn(sessionId: string, content: string | SendMessageInput): Promise
+ cancelGeneration(sessionId: string): Promise
+ respondToolInteraction(...args: ToolInteractionArgs): Promise
+}
+```
+
+Pending, retry/edit/delete/clear, and compaction methods remain available to compatibility routes.
+The internal initial-message method preserves current fire-and-forget create behavior without going
+through `ChatService` request locks.
+
+### Lifecycle
+
+Lifecycle consumes assignment policy, the initial-turn port, projection mutation, app-session/runtime
+ports, and the existing skill/permission owners. It owns a reusable required deletion transaction
+port so Assignment can perform batch empty-draft deletion without duplicating cleanup ordering.
+
+Create methods continue to return materialized `SessionWithState`; no new public restore or close
+operation is introduced.
+
+## Integration Enumeration
+
+Every real creation/call/injection relationship must be verified. Unit mocks do not satisfy these
+integration tasks by themselves.
+
+| Producer/caller | Consumer | Required integration evidence |
+| --- | --- | --- |
+| `Presenter` composition root | four coordinators | each constructed exactly once with real narrow adapters |
+| composition root | compatibility `AgentSessionPresenter` | forwarding reaches the same coordinator instances |
+| Lifecycle | Assignment policy | create/draft/subagent resolve canonical kind/config through real policy |
+| Lifecycle | Turn initial-message port | initial message is accepted after successful initialization and remains fire-and-forget |
+| Lifecycle | Projection mutation | bind/materialize/notify/forget behavior uses the shared projection instance |
+| Assignment | Lifecycle deletion port | batch deletion uses the real child-first transaction, not a stub implementation |
+| Assignment/Turn | Projection mutation | post-mutation materialization/events use the shared projection instance |
+| `createMainKernelRouteRuntime` | `SessionService` | real lifecycle/projection ports replace presenter adapter |
+| `createMainKernelRouteRuntime` | `ChatService` | real turn/projection/permission ports replace presenter adapter/cast |
+| `RemoteControlPresenter` | four remote ports | runner operations reach the real coordinator set; generation port remains separate |
+| composition root | Cron starter | starter is installed before startup without route-runtime priming |
+| Cron starter | Lifecycle + Turn | detached create, max-turn send, and cancel use real coordinators |
+| compatibility routes/tools | façade | forwarding remains available for consumers deferred to stage 3 |
+
+## Slice 1 — Characterization and Port Contracts
+
+1. Inventory all core presenter methods, private helpers, state, and production/test callers.
+2. Add missing characterization for:
+ - pending update/move/convert/steer/delete;
+ - retry/delete/edit message;
+ - fork and compaction failure paths;
+ - tool-interaction validation;
+ - lifecycle rollback/error precedence;
+ - assignment transfer partial-result and conservative checks;
+ - projection active binding, lightweight cache, malformed read data, and title CAS;
+ - Remote status/output truth table;
+ - Cron metadata/max-turn/output/status semantics.
+3. Define consumer-owned SessionService, ChatService, Remote, and Cron ports using shared DTOs.
+4. Do not define any port as `Pick`.
+
+## Slice 2 — Projection Coordinator
+
+1. Move `sessionStatusSnapshots`, session/message/Tape/trace read projection, active window operations,
+ rename/pin, event publication, UI refresh, materialization, lightweight mapping, and title generation
+ into `SessionProjectionCoordinator`.
+2. Keep stage-1 history search and export code outside this coordinator after integration.
+3. Add owner tests for full vs lightweight behavior, status cache, per-window binding, missing/malformed
+ reads, title generation, and update events.
+4. Add forwarding methods in `AgentSessionPresenter` without duplicating policy/state.
+
+## Slice 3 — Assignment Coordinator
+
+1. Extract shared create/subagent/transfer resolution into the focused assignment policy seam.
+2. Move transfer impact/batch/single commands, settings/ACP controls, and subagent Tape finalize into
+ `SessionAgentAssignmentCoordinator`.
+3. Use Projection mutation for post-commit state/event publication.
+4. Use the required lifecycle deletion transaction for empty-draft/bulk deletion.
+5. Add owner tests for target validation, conservative failure handling, preflight-before-mutation,
+ partial transfer errors, setting mutation order, ACP restrictions, and subagent Tape validation.
+6. Retain façade forwarding for deferred consumers.
+
+## Slice 4 — Turn Coordinator
+
+1. Move send/steer, pending operations, message mutation, clear/cancel, tool interaction, and compaction.
+2. Preserve draft promotion and ACP workdir synchronization ordering.
+3. Provide a narrow initial-turn operation for Lifecycle without routing through `ChatService`.
+4. Add owner tests for method-specific missing-session behavior, queue metadata, cancellation ordering,
+ retry flags, ACP interaction validation, and compaction restrictions.
+5. Retain façade forwarding for deferred consumers.
+
+## Slice 5 — Lifecycle Coordinator
+
+1. Move create/detached/subagent/draft/fork/delete and initialization/cleanup helpers.
+2. Consume Assignment policy, Turn initial-message, and Projection mutation ports.
+3. Preserve create precedence, transaction timing, retries, fire-and-forget initial send, rollback,
+ descriptor-independent deletion, and error precedence.
+4. Add owner tests for all creation variants, failed initialization, ACP draft reuse, fork cleanup,
+ recursive deletion, and shared projection integration.
+5. Retain façade forwarding for deferred consumers.
+
+## Slice 6 — SessionService and ChatService Integration
+
+1. Replace `SessionRepository`/`MessageRepository` presenter adaptation with explicit consumer-owned
+ lifecycle/projection ports.
+2. Replace `ProviderExecutionPort` session methods with a Turn port; keep provider connection methods
+ in provider-specific wiring.
+3. Inject the existing permission cleanup owner directly and remove the presenter intersection cast.
+4. Delete unused message-list hot-path adapters.
+5. Keep route schemas, scheduler timings, retries, locks, and response mapping unchanged.
+6. Add service and dispatcher integration tests with independent coordinator doubles.
+
+## Slice 7 — Remote and Cron Integration
+
+### Remote
+
+1. Replace the full presenter in Remote interfaces, presenter construction, and runner construction
+ with four explicit ports.
+2. Preserve `AgentManagerGenerationPort` for active-event lookup/cancel.
+3. Remove optional `getSearchResults` capability probing; the required projection port is called and
+ its existing failure fallback remains local to Remote.
+4. Replace untyped partial presenter fixtures with typed port stubs.
+
+### Cron
+
+1. Add a Cron-owned starter factory over Lifecycle and Turn ports.
+2. Install the starter in the composition root before lifecycle startup.
+3. Remove `setRunSessionStarter` side effects from route-runtime construction.
+4. Remove route-runtime priming from `cronJobsStartHook`.
+5. Preserve `CronJobRunExecutor` as the narrow consumer of `CronJobRunSessionStarter`.
+
+## Slice 8 — Façade Cleanup, Guards, and Documentation
+
+1. Remove migrated state, policy, private helpers, imports, and direct implementations from
+ `AgentSessionPresenter`; leave only forwarding for the four domains while stage-1 foreign behavior
+ remains with its explicit owners.
+2. Keep `IAgentSessionPresenter` public signatures unchanged in stage 2.
+3. Move behavior tests to coordinator suites; presenter tests cover forwarding/compatibility only.
+4. Add architecture guards that reject:
+ - `IAgentSessionPresenter` imports in SessionService/ChatService hot paths, Remote runner/interfaces,
+ Cron starter, and migrated composition helpers;
+ - duplicate coordinator construction outside the composition root/tests;
+ - coordinator imports of stage-1 foreign owners;
+ - a combined session application façade.
+5. Update current architecture/session/flow/code-navigation docs. Do not rewrite historical BEFORE
+ sections or layered-runtime invariants that remain true.
+6. Review generated architecture baseline changes; regenerate maintained outputs only when the diff is
+ intentional and the relevant tree is clean.
+
+## Test Strategy
+
+### Owner tests
+
+- Lifecycle: creation precedence, initialization, detached/subagent/draft/fork, rollback, recursive
+ deletion, error precedence.
+- Turn: send/steer/pending/message mutation/clear/cancel/tool interaction/compaction.
+- Assignment: policy resolution, transfer, settings, ACP controls, subagent Tape finalize.
+- Projection: full/lightweight session state, message/Tape/trace projection, active binding, title,
+ status cache, events.
+
+### Consumer tests
+
+- `SessionService`: create/restore/list/page/activate/deactivate/get-active with exact timeout/retry
+ behavior.
+- `ChatService`: send lock, unavailable session/agent, steer, stop-by-request, timeout cleanup, tool
+ interaction.
+- Remote: command and status/output truth table over typed port stubs.
+- Cron: starter metadata, ACP/pinned model snapshot, max-turn mapping, cancellation, executor output and
+ terminal fencing.
+
+### Integration tests
+
+- Composition identity proves one shared coordinator set reaches façade, routes, Remote, and Cron.
+- Real coordinator chains cover Lifecycle → Assignment/Turn/Projection and Assignment/Turn →
+ Projection without mocks at those boundaries.
+- Dispatcher tests prove unchanged route contracts while using independent coordinator doubles.
+- Startup test proves Cron no longer primes route runtime.
+
+### Regression gates
+
+```text
+pnpm run format
+pnpm run i18n
+pnpm run lint
+pnpm run typecheck
+pnpm run test:main
+pnpm run lint:architecture
+git diff --check
+```
+
+Run `pnpm run architecture:baseline` only after reviewing a temporary generated diff and only from a
+clean relevant tree.
+
+## Compatibility and Rollback
+
+- No schema, route, event, or persisted-data migration is introduced; rollback is code-only.
+- Each coordinator slice keeps façade forwarding, so later slices can be reverted independently.
+- Do not remove a façade implementation until owner tests and all migrated consumers call the new
+ owner.
+- Stage-1 integration was completed by merging `dev@135779210`. The resolution preserved stage-1
+ foreign owner wiring and stage-2 coordinator wiring file by file; no presenter, composition, or
+ route conflict was accepted wholesale.
+
+## Exit Gate
+
+This goal is complete only when all spec acceptance criteria pass, all four coordinators are the sole
+owners of their domain behavior, the four named consumer groups use narrow ports, the compatibility
+façade contains forwarding rather than duplicated logic, architecture guards are active, current docs
+are updated, and every task in `tasks.md` is closed.
diff --git a/docs/architecture/session-application-coordinators/spec.md b/docs/architecture/session-application-coordinators/spec.md
new file mode 100644
index 0000000000..3430e450f8
--- /dev/null
+++ b/docs/architecture/session-application-coordinators/spec.md
@@ -0,0 +1,322 @@
+# Session Application Coordinators
+
+> Status: implemented, integrated, and validated
+> Original base: `dev@28e2a0e92`
+> Integrated base: `dev@135779210` via merge commit `1122b2406`
+> Branch: `task/session-application-coordinators`
+
+## Context
+
+The layered runtime migration established the execution boundary:
+
+- `AppSessionService` owns the persisted app-session shell and window bindings;
+- `AgentManager` resolves strict descriptors and typed DeepChat/direct-ACP handles;
+- backend instances own runtime state and turn execution;
+- transcript, Tape, permission, skill, and UI adapters retain their existing data ownership.
+
+`AgentSessionPresenter` still combines four application responsibilities above those owners:
+
+1. session lifecycle transactions;
+2. turn commands and message mutations;
+3. agent/runtime assignment policy and transfer;
+4. renderer-facing session, message, Tape, title, status, and window projections.
+
+It is also the common dependency of typed session/chat services, Remote, Cron, subagent tooling, and
+compatibility routes. Consumers therefore depend on a large presenter contract even when they need
+only one or two operations.
+
+This goal is stage 2 of the session boundary cleanup sequence:
+
+1. foreign capability cleanup (`codex/session-boundary-cleanup`, merged in PR #1957);
+2. **session application coordinators** (this goal);
+3. presenter retirement after all remaining compatibility consumers are rewired.
+
+This branch was developed from `dev@28e2a0e92`, independently of stage 1, so both ownership changes
+could be reviewed separately. It later merged `dev@135779210`; the integration preserves both splits
+across the composition root, presenter, routes, guards, tests, current docs, and architecture
+baselines.
+
+## Problem
+
+The current façade produces five concrete architecture failures:
+
+1. `SessionService` and `ChatService` reach application behavior through
+ `createPresenterHotPathPorts(IAgentSessionPresenter)` instead of explicit owners.
+2. `RemoteConversationRunner` receives the complete `IAgentSessionPresenter` despite using a fixed,
+ narrow subset of lifecycle, turn, assignment, and projection operations.
+3. Cron wiring is installed as a side effect of `createMainKernelRouteRuntime`, so startup must prime
+ the route runtime before `CronJobsService.start()`.
+4. `AgentSessionPresenter` owns policy, transaction ordering, window/status caches, and title state,
+ making these invariants difficult to test without a 4,000-line fixture.
+5. `ChatService` obtains permission cleanup through a presenter-only intersection cast instead of the
+ existing permission owner.
+
+## Goals
+
+1. Extract four composition-owned application coordinators:
+ `SessionLifecycleCoordinator`, `SessionTurnCoordinator`,
+ `SessionAgentAssignmentCoordinator`, and `SessionProjectionCoordinator`.
+2. Move the corresponding policy, state, transaction ordering, private helpers, and tests out of
+ `AgentSessionPresenter`.
+3. Keep `AgentSessionPresenter` as a compatibility façade that forwards existing public methods to
+ the four coordinators. Presenter retirement remains stage 3.
+4. Make `SessionService`, `ChatService`, Remote, and Cron depend on consumer-owned narrow ports rather
+ than `IAgentSessionPresenter`.
+5. Construct one shared coordinator set in the composition root and reuse it across the compatibility
+ façade, routes, Remote, Cron, tools, and other migrated consumers.
+6. Remove Cron's route-runtime priming dependency and wire its existing
+ `CronJobRunSessionStarter` from the composition root.
+7. Preserve route/event/data contracts and all DeepChat/direct-ACP behavior.
+8. Add architecture enforcement that prevents the migrated consumers from regaining the presenter
+ dependency or an aggregate replacement façade.
+
+## Non-goals
+
+- Reimplementing or absorbing stage-1 history, import, migration, usage, RTK, export, translation, or
+ catalog ownership. After integration those capabilities remain with their explicit stage-1 owners.
+- Removing `AgentSessionPresenter`, deleting `IAgentSessionPresenter`, or rewiring every remaining
+ compatibility caller. That is stage 3.
+- Moving runtime state out of `DeepChatAgentInstance`, `AcpAgentInstance`, or `LoopRun`.
+- Reworking `AgentManager`, backend handles, transcript/Tape schemas, permission policy, skills,
+ providers, or ACP protocol behavior.
+- Changing renderer route names, inputs, outputs, typed events, preload clients, database schemas, or
+ persisted formats.
+- Changing `SessionService`/`ChatService` timeout, retry, locking, cancellation, or error semantics.
+- Changing Remote commands, bindings, status/output projection, active-event cancellation, or media
+ delivery.
+- Changing Cron scheduling, snapshot policy, detached-session metadata, completion detection,
+ timeout, output ordering, or delivery behavior.
+- Introducing a generic service container, command bus, repository hierarchy, coordinator registry,
+ or combined `SessionApplicationServices` façade.
+- GitHub issue synchronization.
+
+## Ownership Decisions
+
+### SessionLifecycleCoordinator
+
+Owns lifecycle transactions and their rollback/cleanup ordering:
+
+- create window-bound sessions;
+- create detached sessions;
+- create subagent sessions;
+- ensure/reuse ACP draft sessions;
+- fork sessions;
+- delete a session tree;
+- runtime initialization, ACP workdir synchronization, and failed-create cleanup.
+
+It consumes narrow assignment policy, initial-turn, projection mutation, app-session, runtime,
+transcript, skill, and permission ports. It does not own window activation, title generation,
+transfer policy, ordinary turns, or read projection.
+
+### SessionTurnCoordinator
+
+Owns turn commands and message mutation ordering:
+
+- send and steer;
+- pending-input list/queue/update/move/convert/steer/delete;
+- retry/delete/edit message;
+- compaction state and manual compaction;
+- clear session messages;
+- cancel generation;
+- respond to tool interactions;
+- the narrow initial-message operation used by lifecycle creation.
+
+`ChatService` retains request-level locking, scheduler timeouts, stop-by-request lookup, and
+all-settled permission/cancellation cleanup.
+
+### SessionAgentAssignmentCoordinator
+
+Owns executable assignment and runtime-setting policy:
+
+- create/subagent/transfer assignment resolution;
+- transfer impact, batch move/delete, and single-session transfer;
+- model, project, permission, generation settings, disabled tools, and subagent-enabled settings;
+- ACP config options and commands;
+- subagent Tape merge/discard.
+
+Session deletion remains a lifecycle transaction. Assignment may call a required narrow lifecycle
+deletion port, but the composition graph must not use optional setters or circular construction.
+Assignment resolution shared with lifecycle may be implemented as a focused pure policy module; it
+must not become a fifth aggregate coordinator.
+
+### SessionProjectionCoordinator
+
+Owns renderer-facing projection state and projection operations:
+
+- full and lightweight session materialization/listing;
+- message/page/message-id/search-result/trace reads;
+- Tape info/search/context/anchors/handoff/view-manifest/replay projection operations;
+- active window binding and lookup;
+- rename and pin updates;
+- asynchronous title generation and compare-and-set protection;
+- session status snapshot cache;
+- `sessions.updated` publication and session UI refresh.
+
+It is a composition-owned singleton. Creating one instance per consumer is forbidden because window
+bindings and status snapshots must remain coherent.
+
+History search and export remain stage-1 foreign capabilities after integration and must not be
+absorbed into Projection.
+
+## Target Dependency Shape
+
+```text
+Presenter composition root
+ ├─ SessionProjectionCoordinator (one instance)
+ ├─ SessionAgentAssignmentCoordinator (one instance)
+ ├─ SessionTurnCoordinator (one instance)
+ ├─ SessionLifecycleCoordinator (one instance)
+ └─ AgentSessionPresenter compatibility façade
+ └─ forwards existing core methods to the four coordinators
+
+typed routes
+ ├─ SessionService -> lifecycle + projection ports
+ └─ ChatService -> turn + projection + existing permission/catalog ports
+
+RemoteControlPresenter / RemoteConversationRunner
+ ├─ remote lifecycle port
+ ├─ remote turn port
+ ├─ remote assignment port
+ ├─ remote projection port
+ └─ existing AgentManagerGenerationPort
+
+CronJobsService
+ └─ CronJobRunSessionStarter
+ ├─ lifecycle.createDetachedSession
+ └─ turn.sendMessage / cancelGeneration
+```
+
+Ports are owned by the consumer that needs them and use shared route/domain DTOs. They must not be
+defined as `Pick` and must not be grouped under a replacement aggregate.
+
+## Boundary Rules
+
+1. A coordinator may orchestrate existing owners but must not copy their state or persistence logic.
+2. Coordinators depend on the smallest required typed ports. They must not receive the entire
+ `Presenter`, `IAgentSessionPresenter`, `AgentSharedDataPorts`, or concrete SQLite presenter.
+3. The compatibility façade contains forwarding only for migrated core methods. Coordinator state,
+ policy, and private helpers must not remain duplicated there.
+4. `SessionService`, `ChatService`, Remote runner/interfaces, Cron starter, and
+ `createPresenterHotPathPorts` must not import or accept `IAgentSessionPresenter` after migration.
+5. `SessionService` continues to compose restore as projection lookup plus paged messages; there is no
+ new restore coordinator method.
+6. `ChatService.activeControllers` remains in `ChatService`.
+7. Remote generation stop continues through `AgentManagerGenerationPort.cancelGenerationByEventId`;
+ it must not be replaced with ordinary session cancellation.
+8. Cron starter construction occurs in the composition root, not as a route-runtime side effect.
+9. Required dependencies fail fast. No optional coordinator methods, capability probes, no-op
+ fallbacks, or `as unknown as` wiring are permitted.
+10. Stage-1 foreign owners remain independent after integration and are not imported by the new
+ coordinators.
+
+## Compatibility Invariants
+
+### Lifecycle
+
+- Create precedence, missing provider/model/workdir errors, permission normalization, active-skill
+ persistence, and direct-ACP initialization remain unchanged.
+- `createSession(projectDir: null)` continues to suppress project fallback; detached blank/null input
+ keeps its current fallback behavior.
+- Runtime initialization completes before window binding and `created` publication.
+- Initial send remains fire-and-forget; its failure is logged and does not fail creation.
+- Failed initialization removes the app row, best-effort clears ACP compatibility state/closes the
+ handle, and rethrows the original error.
+- Subagent creation keeps at most two attempts with a fresh session ID per attempt and publishes only
+ after successful materialization.
+- ACP draft transcript-read failure remains conservative: the draft is not reused.
+- Delete remains descriptor-independent, child-first, and preserves backend/shared-data error
+ precedence and permission/skill/app-row/status cleanup ordering.
+- Normal delete never becomes `resolveSessionHandle(...).close()`.
+
+### Turn
+
+- Send/steer/queue promote drafts before runtime work and do not roll back promotion on later failure.
+- Queue source, project directory, `emitRefreshBeforeStream`, and `maxProviderRounds` forwarding remain
+ unchanged.
+- Missing-session behavior remains method-specific: pending list returns `[]`, cancellation is a
+ no-op, and other mutations throw the existing error.
+- Delete/clear cancel before mutation; edit does not cancel.
+- Direct ACP tool interaction and DeepChat/manual compaction restrictions remain unchanged.
+- Session/Chat routes retain existing scheduler timeout values, send lock, retry, stop, and
+ all-settled cleanup behavior.
+
+### Assignment
+
+- Transfer validates the target and all batch entries before mutation; ACP targets and DeepChat
+ targets whose default provider is ACP remain rejected.
+- Conservative transcript/pending checks, active-generation blocking, sample limits, partial-transfer
+ error text, update order, and post-commit ACP cleanup remain unchanged.
+- Project updates retain their current non-transactional order; no rollback is introduced.
+- ACP model lock, workdir requirement, permission modes, generation settings, disabled tools, and
+ config/command behavior remain unchanged.
+- Subagent parent/slot/agent validation, ACP forced runtime settings, and Tape merge/discard parent
+ checks remain unchanged.
+
+### Projection
+
+- Full session snapshots may hydrate the backend; restore must not become a pure database read.
+- Lightweight lists do not hydrate, retain cached/default status, dedupe/order rules, cursor behavior,
+ and unavailable-agent skip behavior.
+- Message/page/Tape/trace/search-result missing and malformed-data behavior remains unchanged.
+- Active binding remains per-window; activation does not add session validation, and failed active
+ projection continues to unbind without emitting a new deactivation event.
+- Title generation remains asynchronous, waits at most 30 seconds with 250 ms polling, uses the
+ assistant-model preference/fallback, performs both compare-and-set checks, normalizes to 80
+ characters, and only warns on failure.
+- Event ID trim/dedupe, reason defaults, active-session payloads, and UI refresh behavior remain
+ unchanged.
+
+### Remote
+
+- Deleted bindings are cleared; missing bindings and completed/no-response/pending-interaction status
+ projections keep their current payloads.
+- `/open`, model selection, search-result fallback, old-event filtering, generated-image text, and
+ pending interaction behavior remain unchanged.
+- Remote channel command/router public APIs remain unchanged.
+
+### Cron
+
+- Every run creates a new detached session with unchanged source/job/run/scheduled metadata.
+- ACP routing, pinned-model and snapshot policy, system-instruction precedence, and max-turn mapping
+ remain unchanged.
+- `messageId` remains the output message ID; completion remains event-driven.
+- Output segment precedence/labels, concurrency skip/queue, timeout cleanup order, late-failure
+ fencing, and delivery behavior remain unchanged.
+
+## Acceptance Criteria
+
+1. One composition-owned instance exists for each of the four coordinators.
+2. Core lifecycle, turn, assignment, and projection implementation/state/private helpers no longer
+ live in `AgentSessionPresenter`; its existing core public API forwards to coordinators.
+3. `SessionService` uses explicit lifecycle/projection ports and `ChatService` uses explicit
+ turn/projection/permission/catalog ports without `IAgentSessionPresenter`.
+4. `createPresenterHotPathPorts` no longer imports or adapts `IAgentSessionPresenter`; unused message
+ list adapters and the permission intersection cast are gone.
+5. Remote presenter/runner receives separate lifecycle, turn, assignment, and projection ports and no
+ longer imports or accepts `IAgentSessionPresenter`.
+6. Cron receives its existing starter from the composition root; route runtime no longer installs it,
+ and the Cron startup hook no longer primes route runtime.
+7. `SessionService`, `ChatService`, Remote, and Cron route/command/output contracts and behavior remain
+ unchanged under characterization and integration tests.
+8. Remaining non-target consumers either use a narrow coordinator port or the explicit compatibility
+ façade; no aggregate replacement is introduced.
+9. Architecture guards reject presenter dependency reintroduction in migrated consumers, duplicate
+ coordinator construction, and coordinator imports of stage-1 foreign owners.
+10. Current architecture, flow, session-management, and code-navigation docs describe the four
+ coordinators and narrow consumer ports without rewriting historical runtime invariants.
+11. Formatting, i18n validation, lint, full typecheck, main tests, and architecture guards pass.
+
+## Risks
+
+- Moving 4,000 lines of interleaved behavior can silently change transaction ordering. Tests must be
+ relocated by owner before the façade helpers are deleted.
+- Projection must remain singleton; duplicate caches would produce inconsistent status/window views.
+- Lifecycle, Turn, and Assignment have real call chains. Breaking their construction with optional
+ setters would replace one service-locator problem with another.
+- Full session projection intentionally hydrates runtime state. Treating Projection as a read-only
+ repository would break restore and Remote behavior.
+- Remote and Cron often receive `{ requestId: null, messageId: null }` because send means accepted,
+ not completed. Coordinator extraction must not await generation completion.
+- Stage 1 and stage 2 both touch the presenter, composition root, routes, tests, guards, and docs.
+ Integration requires a deliberate rebase/merge review; neither branch may overwrite the other's
+ owner wiring.
diff --git a/docs/architecture/session-application-coordinators/tasks.md b/docs/architecture/session-application-coordinators/tasks.md
new file mode 100644
index 0000000000..19918c5689
--- /dev/null
+++ b/docs/architecture/session-application-coordinators/tasks.md
@@ -0,0 +1,97 @@
+# Session Application Coordinators — Tasks
+
+## SDD and Inventory
+
+- [x] Audit Lifecycle, Turn, AgentAssignment, and Projection methods, state, dependencies, and tests.
+- [x] Enumerate SessionService, ChatService, Remote, and Cron create/call/inject chains.
+- [x] Resolve ownership of active-window state, title, draft, transfer, permission cleanup, and Cron
+ starter wiring.
+- [x] Write the approved spec and implementation plan from `dev@28e2a0e92`.
+
+## 1. Characterization and Ports
+
+- [x] Add missing lifecycle rollback and deletion error-precedence characterization.
+- [x] Add pending/message mutation, fork, compaction, and tool-interaction characterization.
+- [x] Lock assignment transfer, setting mutation, and subagent Tape behavior.
+- [x] Lock projection cache/window/title/read fallback behavior.
+- [x] Lock Remote status/output and Cron metadata/max-turn/output behavior.
+- [x] Define consumer-owned narrow ports without `Pick`.
+
+## 2. SessionProjectionCoordinator
+
+- [x] Extract full and lightweight session materialization and status cache.
+- [x] Extract message, Tape, trace, manifest, replay, and search-result projection operations.
+- [x] Extract active-window binding, rename/pin, title generation, events, and UI refresh.
+- [x] Construct one composition-owned Projection instance.
+- [x] Rewire compatibility presenter forwarding and move owner tests.
+
+## 3. SessionAgentAssignmentCoordinator
+
+- [x] Extract focused create/subagent/transfer assignment policy.
+- [x] Extract transfer impact, batch/single transfer, and agent-session deletion orchestration.
+- [x] Extract model/project/permission/generation/tools/subagent settings and ACP controls.
+- [x] Extract subagent Tape merge/discard.
+- [x] Use narrow lifecycle deletion and projection mutation ports without circular construction.
+- [x] Rewire compatibility presenter forwarding and move owner tests.
+
+## 4. SessionTurnCoordinator
+
+- [x] Extract send, steer, and pending-input operations.
+- [x] Extract retry/delete/edit/clear message operations.
+- [x] Extract cancellation, tool-interaction response, and compaction.
+- [x] Add the narrow initial-turn operation used by Lifecycle.
+- [x] Rewire compatibility presenter forwarding and move owner tests.
+
+## 5. SessionLifecycleCoordinator
+
+- [x] Extract create, detached, subagent, ACP draft, fork, and recursive delete transactions.
+- [x] Extract runtime initialization, workdir sync, and failed-create cleanup.
+- [x] Connect real Assignment policy, Turn initial-message, and Projection mutation owners.
+- [x] Rewire compatibility presenter forwarding and move owner tests.
+
+## 6. SessionService and ChatService
+
+- [x] Inject Lifecycle/Projection ports into `SessionService`.
+- [x] Inject Turn/Projection and existing permission/catalog ports into `ChatService`.
+- [x] Remove the `IAgentSessionPresenter` hot-path adapter, unused message adapter, and permission cast.
+- [x] Preserve route schemas, timeout/retry/lock/cleanup semantics, and add integration tests.
+
+## 7. Remote and Cron
+
+- [x] Inject separate Lifecycle, Turn, Assignment, and Projection ports into Remote.
+- [x] Keep Remote active-generation lookup/cancel on `AgentManagerGenerationPort`.
+- [x] Replace untyped Remote presenter fixtures with typed port stubs.
+- [x] Build the Cron starter from Lifecycle/Turn in the composition root.
+- [x] Remove route-runtime starter side effects and startup route-runtime priming.
+- [x] Preserve Cron metadata, max-turn, output, status, timeout, and delivery semantics.
+
+## 8. Façade and Enforcement
+
+- [x] Remove migrated implementation state/helpers/imports from `AgentSessionPresenter`.
+- [x] Keep stage-2 compatibility signatures and forwarding; do not retire the façade.
+- [x] Exhaust production/test searches for presenter dependencies in migrated consumers.
+- [x] Add architecture guards for consumer imports, duplicate construction, foreign-owner imports,
+ and combined façade regression.
+- [x] Update current architecture, session management, flows, and code navigation.
+- [x] Review the dependency diff and regenerate maintained baselines only when intentional.
+
+## 9. Validation
+
+- [x] Run focused coordinator, service, route, Remote, Cron, composition, and guard tests.
+- [x] Run `pnpm run format`.
+- [x] Run `pnpm run i18n`.
+- [x] Run `pnpm run lint`.
+- [x] Run `pnpm run typecheck`.
+- [x] Run `pnpm run test:main`.
+- [x] Run `pnpm run lint:architecture` and `git diff --check`.
+- [x] Confirm every acceptance criterion in `spec.md` and close this task list.
+
+## 10. Stage 1 Integration
+
+- [x] Merge the latest `dev`, including Stage 1 PR #1957 and subsequent #1958/#1960 changes.
+- [x] Preserve Stage 1 foreign owners and Stage 2 coordinators across composition, routes, and the
+ compatibility façade.
+- [x] Reconcile tests, guards, current docs, and architecture baselines without restoring a broad
+ presenter dependency.
+- [x] Run focused and full validation.
+- [x] Push the integration commits and confirm PR #1961 is mergeable.
diff --git a/docs/architecture/session-boundary-cleanup/spec.md b/docs/architecture/session-boundary-cleanup/spec.md
index 9027185e54..213b386a65 100644
--- a/docs/architecture/session-boundary-cleanup/spec.md
+++ b/docs/architecture/session-boundary-cleanup/spec.md
@@ -1,7 +1,7 @@
# Session Boundary Cleanup
-> Status: approved for implementation
-> Base: `dev@28e2a0e92`
+> Status: implemented and merged in PR #1957
+> Base: `dev@28e2a0e92`
> Branch: `codex/session-boundary-cleanup`
## Context
diff --git a/docs/architecture/session-management.md b/docs/architecture/session-management.md
index 24012848d8..60f9dc15e4 100644
--- a/docs/architecture/session-management.md
+++ b/docs/architecture/session-management.md
@@ -1,13 +1,15 @@
# 会话管理架构详解
-当前会话管理分成四个明确边界:
+当前会话管理分成五个明确边界:
- app-session shell:`AppSessionService` 管理 `new_sessions`、window binding 和 shared CRUD;
+- application coordination:四个 `sessionApplication` coordinator 分别拥有 Lifecycle、Turn、
+ AgentAssignment 和 Projection 不变量;
- agent execution routing:`AgentManager` 按 executable descriptor kind 返回 typed handle;
- route/application owners:typed session routes 直接组合 search、translation、export、usage、RTK 与 agent
catalog owner;
-- core session façade:`AgentSessionPresenter` 只保留 session lifecycle、turn、assignment 与 shared
- projection 编排。
+- compatibility forwarding:`AgentSessionPresenter` 保留尚未退休的 public surface,并把 core session 方法
+ 转发到四个 coordinator,不拥有 application behavior。
`SessionPresenter` 是旧 conversations/messages 的 compatibility/data façade,不在当前 agent execution
链路中。
@@ -16,7 +18,8 @@
| 组件 | 位置 | 当前职责 |
| --- | --- | --- |
-| `AgentSessionPresenter` | `src/main/presenter/agentSessionPresenter/index.ts` | core session façade;CRUD、draft、title、turn、transfer/subagent 与 shared projection |
+| Session application coordinators | `src/main/presenter/sessionApplication/` | Lifecycle、Turn、AgentAssignment、Projection application invariants |
+| `AgentSessionPresenter` | `src/main/presenter/agentSessionPresenter/index.ts` | core session public compatibility forwarding;不拥有 application behavior |
| Session route owners | `src/main/routes/sessions/` | history search、translation 与 typed route orchestration |
| Session export | `src/main/presenter/exporter/agentSessionExporter.ts` | current agent-session export mapping and format dispatch |
| Startup/maintenance owners | `src/main/presenter/startupMigrations/`, `usageStatsService.ts` | legacy import、session-data migrations、usage backfill/dashboard;RTK 由其 runtime service 自有 |
@@ -35,22 +38,22 @@
```mermaid
sequenceDiagram
participant R as Renderer
- participant F as AgentSessionPresenter
+ participant SS as SessionService / ChatService
+ participant L as Lifecycle / Turn
+ participant A as Assignment / Projection
participant S as AppSessionService
participant M as AgentManager
participant B as Typed Backend Handle
- R->>F: createSession(input)
- F->>M: resolveBackend(agentId)
- M-->>F: strict descriptor + backend
- F->>S: create app-session row
- F->>B: initialize effective config/workdir
- F->>S: bindWindow()
- R->>F: sendMessage(sessionId)
- F->>M: resolveSessionHandle(sessionId)
+ R->>SS: sessions.create / chat.sendMessage
+ SS->>L: createSession / sendMessage
+ L->>A: resolve assignment / update projection
+ L->>M: resolve backend or session handle
+ M-->>L: strict descriptor + typed handle
+ L->>S: create/update app-session row
+ L->>B: initialize or send
+ A->>S: bind/materialize/notify
M->>S: read current agentId
- M-->>F: DeepChatSessionHandle or DirectAcpSessionHandle
- F->>B: send(input)
```
`new_sessions.session_kind` 只表示 `regular | subagent`。DeepChat/ACP routing 只来自当前
@@ -98,7 +101,7 @@ manager cleanup both backend caches without hydration
-> delete app-session row
```
-façade 最后调用 `AppSessionService.delete()` 才删除 `new_sessions` row;因此即使 backend close 已完成,app
+Lifecycle deletion transaction 最后调用 `AppSessionService.delete()` 才删除 `new_sessions` row;因此即使 backend close 已完成,app
session shell 仍存在,直到 full delete 明确提交。这允许 missing/disabled/malformed agent row 的旧 session
仍可删除。ACP -> DeepChat transfer 先完成 target validation和 ownership commit,再关闭旧 direct ACP
runtime;ACP target 在 mutation 前拒绝。DeepChat +
@@ -108,8 +111,10 @@ ACP-provider source 只清 compatibility binding,不被误判成 direct ACP。
- Subagent 与普通 session 共享 app/message schema,用 `sessionKind`、`parentSessionId`、`subagentMeta`
区分;child backend 仍由 manager 选择,父 session 通过 Tape merge/discard 接收结果。
-- Remote active-generation lookup/cancel 使用 `AgentManagerGenerationPort`,不扫描 presenter runtime maps。
-- Cron 每次执行创建 detached app session,再通过 façade/manager handle send;Remote delivery 只是通知。
+- Remote 通过四个 consumer-owned session ports 调用 coordinator;active-generation lookup/cancel 仍使用
+ `AgentManagerGenerationPort`,不扫描 presenter runtime maps。
+- Cron 的 composition-owned starter 通过 Lifecycle 创建 detached app session、通过 Turn send/cancel;
+ Remote delivery 只是通知,route runtime 不参与 starter 接线。
## `SessionPresenter` compatibility
@@ -121,8 +126,10 @@ ACP-provider source 只清 compatibility binding,不被误判成 direct ACP。
- tab/window close compatibility;
- exporter 的旧消息格式化。
-当前 session create/send/cancel/tool interaction 从 `AgentSessionPresenter -> AgentManager -> typed backend`
-开始追踪;DeepChat state/loop 看 `agent/deepchat`,direct ACP 看 `agent/acp/instance`。
+当前 session create/send/cancel/tool interaction 从 `SessionService/ChatService -> sessionApplication
+coordinator -> AgentManager -> typed backend` 开始追踪。兼容调用可能先进入 `AgentSessionPresenter`,但其
+core session methods 只转发到同一 coordinator 实例。DeepChat state/loop 看 `agent/deepchat`,direct ACP
+看 `agent/acp/instance`。
旧聊天数据导入由 `presenter/startupMigrations/legacyChatImportService.ts` 拥有;当前 agent-session export
由 `presenter/exporter/agentSessionExporter.ts` 拥有。两者都不属于 `AgentSessionPresenter`。
diff --git a/docs/guides/code-navigation.md b/docs/guides/code-navigation.md
index 228cfa8e93..45167d2b70 100644
--- a/docs/guides/code-navigation.md
+++ b/docs/guides/code-navigation.md
@@ -21,10 +21,11 @@ copy/file/openExternal 等 dedicated preload 能力,并通过 renderer client
6. `src/main/routes/sessions/sessionService.ts`
7. `src/main/routes/sessions/sessionHistorySearch.ts` / `sessionTranslation.ts`
8. `src/main/routes/chat/chatService.ts`
-9. `src/main/routes/providers/providerService.ts`
-10. `src/main/routes/hotPathPorts.ts`
-11. `src/main/presenter/agentSessionPresenter/index.ts`
-12. `src/main/presenter/agentRuntimePresenter/index.ts`
+9. `src/main/presenter/sessionApplication/`
+10. `src/main/routes/providers/providerService.ts`
+11. `src/main/routes/hotPathPorts.ts`
+12. `src/main/presenter/agentSessionPresenter/index.ts`
+13. `src/main/presenter/agentRuntimePresenter/index.ts`
## 按边界找代码
@@ -54,15 +55,16 @@ copy/file/openExternal 等 dedicated preload 能力,并通过 renderer client
| 功能 | 位置 | 备注 |
| --- | --- | --- |
| session route dispatch | `src/main/routes/index.ts` | `sessions.create` / `restore` / `listMessagesPage` / `activate` / `deactivate` / `getActive` |
-| session orchestration | `src/main/routes/sessions/sessionService.ts` | `Scheduler` + session/message repositories |
+| session orchestration | `src/main/routes/sessions/sessionService.ts` | `Scheduler` + Lifecycle/Projection consumer ports |
| session history / translation | `src/main/routes/sessions/sessionHistorySearch.ts` / `sessionTranslation.ts` | route-owned search fallback and translation policy execution |
| session export | `src/main/presenter/exporter/agentSessionExporter.ts` | current agent-session mapping and format dispatch |
| usage / startup maintenance | `src/main/presenter/usageStatsService.ts` / `startupMigrations/` | dashboard/backfill、legacy import、session-data migrations |
| available-agent catalog | `src/main/agent/shared/availableAgentCatalog.ts` | DeepChat/ACP visibility policy |
| chat route dispatch | `src/main/routes/index.ts` | `chat.sendMessage` / `stopStream` / `respondToolInteraction` |
-| chat orchestration | `src/main/routes/chat/chatService.ts` | send / stop / permission response owner |
+| chat orchestration | `src/main/routes/chat/chatService.ts` | request lock/timeout/stop + Turn/Projection/permission ports |
| scheduler | `src/main/routes/scheduler.ts` | timeout / retry / abort 统一入口 |
-| presenter-backed ports | `src/main/routes/hotPathPorts.ts` | route services 依赖的最小 runtime port |
+| session application | `src/main/presenter/sessionApplication/` | Lifecycle、Turn、AgentAssignment、Projection owner |
+| provider hot-path ports | `src/main/routes/hotPathPorts.ts` | provider catalog/connection adapter;不承载 session façade |
### Provider / Permission
@@ -78,7 +80,8 @@ copy/file/openExternal 等 dedicated preload 能力,并通过 renderer client
| 功能 | 位置 | 备注 |
| --- | --- | --- |
-| session runtime entry | `src/main/presenter/agentSessionPresenter/index.ts` | window/session 绑定、turn/assignment 与 runtime delegation |
+| session application entry | `src/main/presenter/sessionApplication/` | core session transaction/policy/projection owners |
+| compatibility session façade | `src/main/presenter/agentSessionPresenter/index.ts` | core session public compatibility forwarding only |
| message runtime entry | `src/main/presenter/agentRuntimePresenter/index.ts` | `processMessage()`、暂停恢复、stream 生命周期 |
| 主循环 | `src/main/presenter/agentRuntimePresenter/process.ts` | stream + tool loop |
| 工具调度 | `src/main/presenter/agentRuntimePresenter/dispatch.ts` | tool call / paused interaction |
@@ -121,7 +124,7 @@ rg "settingsChangedEvent|sessionsUpdatedEvent|chatStream" src/shared src/main sr
| --- | --- |
| `renderer/api/*Client` | migrated renderer boundary 的一线入口 |
| `src/main/routes/*` | migrated settings/session/chat/provider path 的 active owner |
-| `agentSessionPresenter` | core session lifecycle/turn/assignment collaborator;不拥有 search/export/usage/startup/catalog |
+| `agentSessionPresenter` | core session public compatibility forwarding;不拥有 search/export/usage/startup/catalog,也不是 migrated renderer 的直连入口 |
| `agentRuntimePresenter` | 当前聊天 runtime 与持久化 owner |
| `SessionPresenter` | legacy conversation 兼容层,不是 migrated chat 主链路 |
| `agentPresenter` | 已退休;只应出现在旧提交或已删除的历史 spec 里 |
diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md
index 8d5e4f3d62..d2f1989505 100644
--- a/docs/guides/getting-started.md
+++ b/docs/guides/getting-started.md
@@ -42,8 +42,9 @@ Renderer
-> window.deepchat
-> shared/contracts/routes + shared/contracts/events
-> src/main/routes/*
- -> presenter-backed hot path ports
- -> agentSessionPresenter / agentRuntimePresenter / toolPresenter / llmProviderPresenter
+ -> SessionService / ChatService consumer-owned ports
+ -> sessionApplication coordinators
+ -> AgentManager / typed backend / retained resource presenters
```
如果你在旧提交里看到 `AgentPresenter`、`startStreamCompletion`、`agentLoopHandler`,
@@ -61,13 +62,16 @@ Renderer
src/
├── main/
│ ├── presenter/
-│ │ ├── agentSessionPresenter/ # 当前会话入口
-│ │ ├── agentRuntimePresenter/ # 当前聊天 runtime
-│ │ ├── toolPresenter/ # 工具路由
-│ │ │ └── agentTools/ # 本地 agent tools
-│ │ ├── llmProviderPresenter/ # provider 管理与 ACP provider adapter
-│ │ ├── mcpPresenter/ # MCP tools/runtime
-│ │ ├── sessionPresenter/ # legacy 数据兼容层
+│ │ ├── sessionApplication/ # core session application owners
+│ │ ├── agentSessionPresenter/ # core session compatibility forwarding only
+│ │ ├── exporter/ # current agent-session export owner
+│ │ ├── startupMigrations/ # legacy import and session-data migrations
+│ │ ├── agentRuntimePresenter/ # 当前聊天 runtime
+│ │ ├── toolPresenter/ # 工具路由
+│ │ │ └── agentTools/ # 本地 agent tools
+│ │ ├── llmProviderPresenter/ # provider 管理与 ACP provider adapter
+│ │ ├── mcpPresenter/ # MCP tools/runtime
+│ │ ├── sessionPresenter/ # legacy 数据兼容层
│ │ └── ...
│ ├── agent/
│ │ ├── acp/ # ACP catalog/client/runtime owner
@@ -90,9 +94,10 @@ src/
5. `src/main/routes/index.ts`
6. `src/main/routes/sessions/sessionService.ts`
7. `src/main/routes/chat/chatService.ts`
-8. `src/main/routes/providers/providerService.ts`
-9. `src/main/presenter/agentSessionPresenter/index.ts`
-10. `src/main/presenter/agentRuntimePresenter/index.ts`
+8. `src/main/presenter/sessionApplication/`
+9. `src/main/routes/providers/providerService.ts`
+10. `src/main/presenter/agentSessionPresenter/index.ts`
+11. `src/main/presenter/agentRuntimePresenter/index.ts`
## 常见开发任务
@@ -100,7 +105,9 @@ src/
优先看:
-- `src/main/presenter/agentSessionPresenter/index.ts`
+- `src/main/routes/chat/chatService.ts`
+- `src/main/presenter/sessionApplication/turnCoordinator.ts`
+- `src/main/agent/manager/agentManager.ts`
- `src/main/presenter/agentRuntimePresenter/process.ts`
- `src/main/presenter/agentRuntimePresenter/dispatch.ts`
diff --git a/scripts/architecture-guard.mjs b/scripts/architecture-guard.mjs
index 7ed5f0a2e8..64a852ae6b 100644
--- a/scripts/architecture-guard.mjs
+++ b/scripts/architecture-guard.mjs
@@ -72,6 +72,61 @@ const AGENT_RUNTIME_PRESENTER_ROOT = path.join(
const MEMORY_PRESENTER_ROOT = path.join(ROOT, 'src/main/presenter/memoryPresenter')
const SQLITE_PRESENTER_ROOT = path.join(ROOT, 'src/main/presenter/sqlitePresenter')
const PRESENTER_ROOT_ENTRY = path.join(ROOT, 'src/main/presenter/index.ts')
+const SESSION_APPLICATION_ROOT = path.join(ROOT, 'src/main/presenter/sessionApplication')
+const SESSION_APPLICATION_OWNER_PATHS = new Set(
+ [
+ 'projectionCoordinator.ts',
+ 'agentAssignmentPolicy.ts',
+ 'agentAssignmentCoordinator.ts',
+ 'turnCoordinator.ts',
+ 'lifecycleCoordinator.ts',
+ 'lifecycleDeletionTransaction.ts'
+ ].map((fileName) => path.resolve(SESSION_APPLICATION_ROOT, fileName))
+)
+const SESSION_APPLICATION_OWNER_NAMES = new Set([
+ 'SessionProjectionCoordinator',
+ 'SessionAgentAssignmentPolicy',
+ 'SessionAgentAssignmentCoordinator',
+ 'SessionTurnCoordinator',
+ 'SessionLifecycleCoordinator',
+ 'SessionDeletionTransaction'
+])
+const SESSION_MIGRATED_CONSUMER_PATHS = new Set(
+ [
+ 'src/main/routes/sessions/sessionService.ts',
+ 'src/main/routes/chat/chatService.ts',
+ 'src/main/routes/hotPathPorts.ts',
+ 'src/main/presenter/remoteControlPresenter/index.ts',
+ 'src/main/presenter/remoteControlPresenter/interface.ts',
+ 'src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts',
+ 'src/main/presenter/cronJobs/runSessionStarter.ts',
+ 'src/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.ts'
+ ].map((fileName) => path.resolve(ROOT, fileName))
+)
+const SESSION_COORDINATOR_WHOLE_DEPENDENCY_NAMES = new Set([
+ 'Presenter',
+ 'IAgentSessionPresenter',
+ 'AgentSharedDataPorts',
+ 'SQLitePresenter'
+])
+const SESSION_COMBINED_FACADE_NAMES = new Set([
+ 'SessionApplicationServices',
+ 'SessionApplicationCoordinator'
+])
+const SESSION_FACADE_CAPABILITY_CATEGORIES = new Map([
+ ['SessionLifecyclePort', 'lifecycle'],
+ ['SessionLifecycleCoordinator', 'lifecycle'],
+ ['SessionTurnPort', 'turn'],
+ ['SessionTurnCoordinator', 'turn'],
+ ['SessionAgentAssignmentPort', 'assignment'],
+ ['SessionAgentAssignmentCoordinator', 'assignment'],
+ ['SessionProjectionCoordinator', 'projection'],
+ ['SessionProjectionReadPort', 'projection'],
+ ['SessionProjectionMutationPort', 'projection'],
+ ['SessionWindowProjectionPort', 'projection']
+])
+const SESSION_PHASE_ONE_FOREIGN_IMPORT_PATTERN =
+ /(?:^|[/_.-])(?:history|export(?:er)?|usage(?:[-_.]?stats?)?|rtk|legacy[-_.]?import(?:er|s)?|import(?:er|s)?|migrations?|translation|catalog)(?:$|[/_.-])/
const AGENT_SESSION_PRESENTER_PATH = path.join(
ROOT,
'src/main/presenter/agentSessionPresenter/index.ts'
@@ -256,6 +311,17 @@ function isUnder(targetPath, parentPath) {
)
}
+function isSessionMigratedConsumerPath(filePath) {
+ return SESSION_MIGRATED_CONSUMER_PATHS.has(path.resolve(filePath))
+}
+
+function isSessionApplicationOwnerPath(filePath) {
+ return (
+ SESSION_APPLICATION_OWNER_PATHS.has(path.resolve(filePath)) ||
+ path.basename(filePath).startsWith('__architecture_guard_session_coordinator_')
+ )
+}
+
function isRendererQuarantineFile(filePath) {
return RENDERER_QUARANTINE_ROOTS.some((quarantineRoot) => isUnder(filePath, quarantineRoot))
}
@@ -322,6 +388,438 @@ function sourceFileForAst(source, filePath, scriptKind = ts.ScriptKind.TS) {
)
}
+function importRecordsFromSourceFile(sourceFile) {
+ const records = []
+
+ for (const statement of sourceFile.statements) {
+ if (
+ !ts.isImportDeclaration(statement) ||
+ !statement.importClause ||
+ !ts.isStringLiteralLike(statement.moduleSpecifier)
+ ) {
+ continue
+ }
+
+ const specifier = statement.moduleSpecifier.text
+ if (statement.importClause.name) {
+ records.push({
+ specifier,
+ importedName: 'default',
+ localName: statement.importClause.name.text
+ })
+ }
+
+ const bindings = statement.importClause.namedBindings
+ if (bindings && ts.isNamespaceImport(bindings)) {
+ records.push({ specifier, importedName: '*', localName: bindings.name.text })
+ continue
+ }
+
+ if (bindings && ts.isNamedImports(bindings)) {
+ for (const element of bindings.elements) {
+ records.push({
+ specifier,
+ importedName: element.propertyName?.text ?? element.name.text,
+ localName: element.name.text
+ })
+ }
+ }
+ }
+
+ return records
+}
+
+function findIdentifierNames(sourceFile, names) {
+ const found = new Set()
+ const visit = (node) => {
+ if (ts.isIdentifier(node) && names.has(node.text)) found.add(node.text)
+ const member = accessMemberName(node)
+ if (member && names.has(member)) found.add(member)
+ ts.forEachChild(node, visit)
+ }
+ visit(sourceFile)
+ return found
+}
+
+function findSessionApplicationOwnerConstructions(sourceFile, importRecords) {
+ const constructions = new Map()
+
+ const lookup = (scope, name) => {
+ for (let current = scope; current; current = current.parent) {
+ if (current.bindings.has(name)) return current.bindings.get(name)
+ }
+ return SESSION_APPLICATION_OWNER_NAMES.has(name) ? name : null
+ }
+
+ const resolveOwner = (expression, scope) => {
+ const unwrapped = unwrapExpression(expression)
+ if (ts.isIdentifier(unwrapped)) return lookup(scope, unwrapped.text)
+
+ const owner = accessMemberName(unwrapped)
+ return owner && SESSION_APPLICATION_OWNER_NAMES.has(owner) ? owner : null
+ }
+
+ const addDeclaration = (scope, declarations, declaration) => {
+ if (!ts.isIdentifier(declaration.name)) return
+ scope.bindings.set(declaration.name.text, null)
+ if (declaration.initializer) declarations.push(declaration)
+ }
+
+ const directStatements = (node) =>
+ ts.isSourceFile(node) || ts.isBlock(node) || ts.isModuleBlock(node) ? node.statements : []
+
+ const visitScope = (node, parent) => {
+ const scope = { parent, bindings: new Map() }
+ const declarations = []
+
+ if (ts.isSourceFile(node)) {
+ for (const record of importRecords) {
+ scope.bindings.set(
+ record.localName,
+ SESSION_APPLICATION_OWNER_NAMES.has(record.importedName) ? record.importedName : null
+ )
+ }
+ }
+
+ if (ts.isFunctionLike(node)) {
+ for (const parameter of node.parameters) {
+ if (ts.isIdentifier(parameter.name)) scope.bindings.set(parameter.name.text, null)
+ }
+ }
+
+ for (const statement of directStatements(node)) {
+ if (
+ (ts.isFunctionDeclaration(statement) ||
+ ts.isClassDeclaration(statement) ||
+ ts.isEnumDeclaration(statement)) &&
+ statement.name
+ ) {
+ scope.bindings.set(statement.name.text, null)
+ }
+ if (
+ ts.isVariableStatement(statement) &&
+ (statement.declarationList.flags & ts.NodeFlags.BlockScoped) !== 0
+ ) {
+ for (const declaration of statement.declarationList.declarations) {
+ addDeclaration(scope, declarations, declaration)
+ }
+ }
+ }
+
+ if (ts.isSourceFile(node) || ts.isFunctionLike(node)) {
+ const collectVarDeclarations = (child) => {
+ if (child !== node && ts.isFunctionLike(child)) return
+ if (
+ ts.isVariableDeclarationList(child) &&
+ (child.flags & ts.NodeFlags.BlockScoped) === 0
+ ) {
+ for (const declaration of child.declarations) {
+ addDeclaration(scope, declarations, declaration)
+ }
+ }
+ ts.forEachChild(child, collectVarDeclarations)
+ }
+ collectVarDeclarations(node)
+ }
+
+ let changed = true
+ while (changed) {
+ changed = false
+ for (const declaration of declarations) {
+ const owner = resolveOwner(declaration.initializer, scope)
+ if (owner && scope.bindings.get(declaration.name.text) !== owner) {
+ scope.bindings.set(declaration.name.text, owner)
+ changed = true
+ }
+ }
+ }
+
+ const visit = (child) => {
+ if (child !== node && (ts.isBlock(child) || ts.isFunctionLike(child))) {
+ visitScope(child, scope)
+ return
+ }
+ if (ts.isNewExpression(child)) {
+ const owner = resolveOwner(child.expression, scope)
+ if (owner) constructions.set(owner, (constructions.get(owner) ?? 0) + 1)
+ }
+ ts.forEachChild(child, visit)
+ }
+ ts.forEachChild(node, visit)
+ }
+
+ visitScope(sourceFile, null)
+ return constructions
+}
+
+function findCombinedSessionFacadeDeclarations(sourceFile, importRecords, includeExportedObjects) {
+ const namespaceImports = new Set(
+ importRecords
+ .filter((record) => record.importedName === '*')
+ .map((record) => record.localName)
+ )
+ const capabilities = new Map()
+ for (const record of importRecords) {
+ const category = SESSION_FACADE_CAPABILITY_CATEGORIES.get(record.importedName)
+ if (category) capabilities.set(record.localName, new Set([category]))
+ }
+
+ const typeAliases = []
+ const facades = []
+
+ const mergeCategories = (sets) => new Set(sets.flatMap((set) => [...set]))
+ const sameCategories = (left, right) =>
+ left.size === right.size && [...left].every((category) => right.has(category))
+
+ const qualifiedCategory = (node) => {
+ let namespace = null
+ let member = null
+ if (ts.isQualifiedName(node) && ts.isIdentifier(node.left)) {
+ namespace = node.left.text
+ member = node.right.text
+ } else if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
+ namespace = node.expression.text
+ member = node.name.text
+ }
+ if (!namespace || !namespaceImports.has(namespace)) return new Set()
+ const category = SESSION_FACADE_CAPABILITY_CATEGORIES.get(member)
+ return category ? new Set([category]) : new Set()
+ }
+
+ const referenceCategories = (node, bindings) => {
+ if (ts.isIdentifier(node)) {
+ const category = SESSION_FACADE_CAPABILITY_CATEGORIES.get(node.text)
+ return new Set(bindings.get(node.text) ?? (category ? [category] : []))
+ }
+ return qualifiedCategory(node)
+ }
+
+ const categoriesFor = (node) => {
+ if (ts.isUnionTypeNode(node)) {
+ const [first = new Set(), ...rest] = node.types.map(categoriesFor)
+ return new Set([...first].filter((category) => rest.every((set) => set.has(category))))
+ }
+ if (ts.isIntersectionTypeNode(node)) {
+ return mergeCategories(node.types.map(categoriesFor))
+ }
+ if (ts.isTypeReferenceNode(node)) {
+ return referenceCategories(node.typeName, capabilities)
+ }
+ if (ts.isExpressionWithTypeArguments(node)) {
+ return referenceCategories(node.expression, capabilities)
+ }
+
+ const categories = new Set()
+ ts.forEachChild(node, (child) => {
+ for (const category of categoriesFor(child)) categories.add(category)
+ })
+ return categories
+ }
+
+ const collectTypeAliases = (node) => {
+ if (ts.isTypeAliasDeclaration(node)) typeAliases.push(node)
+ ts.forEachChild(node, collectTypeAliases)
+ }
+ collectTypeAliases(sourceFile)
+ for (const alias of typeAliases) capabilities.set(alias.name.text, new Set())
+
+ let changed = true
+ while (changed) {
+ changed = false
+ for (const alias of typeAliases) {
+ const next = categoriesFor(alias.type)
+ const current = capabilities.get(alias.name.text)
+ if (!sameCategories(next, current)) {
+ capabilities.set(alias.name.text, next)
+ changed = true
+ }
+ }
+ }
+
+ const propertyCategory = (name) => {
+ const normalized = name.replace(/^session/i, '').toLowerCase()
+ if (normalized === 'agentassignment') return 'assignment'
+ return ['lifecycle', 'turn', 'assignment', 'projection'].includes(normalized)
+ ? normalized
+ : null
+ }
+
+ const hasAllPropertyCategories = (object) =>
+ new Set(
+ object.properties
+ .map((property) => property.name && propertyNameText(property.name))
+ .map((name) => name && propertyCategory(name))
+ .filter(Boolean)
+ ).size === 4
+
+ const exportedObjects = []
+ if (includeExportedObjects) {
+ const localObjects = new Map()
+ for (const statement of sourceFile.statements) {
+ if (!ts.isVariableStatement(statement)) continue
+ const exported = statement.modifiers?.some(
+ (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword
+ )
+ for (const declaration of statement.declarationList.declarations) {
+ const initializer = declaration.initializer && unwrapExpression(declaration.initializer)
+ if (
+ !ts.isIdentifier(declaration.name) ||
+ !initializer ||
+ !ts.isObjectLiteralExpression(initializer)
+ ) {
+ continue
+ }
+ localObjects.set(declaration.name.text, { object: initializer, type: declaration.type })
+ if (exported && hasAllPropertyCategories(initializer)) {
+ exportedObjects.push({
+ name: declaration.name.text,
+ object: initializer,
+ type: declaration.type
+ })
+ }
+ }
+ }
+
+ for (const statement of sourceFile.statements) {
+ if (
+ ts.isExportDeclaration(statement) &&
+ !statement.moduleSpecifier &&
+ statement.exportClause &&
+ ts.isNamedExports(statement.exportClause)
+ ) {
+ for (const element of statement.exportClause.elements) {
+ const localName = element.propertyName?.text ?? element.name.text
+ const local = localObjects.get(localName)
+ if (local && hasAllPropertyCategories(local.object)) {
+ exportedObjects.push({ name: element.name.text, ...local })
+ }
+ }
+ }
+ if (ts.isExportAssignment(statement) && !statement.isExportEquals) {
+ const object = unwrapExpression(statement.expression)
+ if (ts.isObjectLiteralExpression(object) && hasAllPropertyCategories(object)) {
+ exportedObjects.push({ name: 'default', object, type: null })
+ }
+ }
+ }
+ }
+
+ if (exportedObjects.length > 0) {
+ const values = new Map()
+ for (const record of importRecords) {
+ const category = SESSION_FACADE_CAPABILITY_CATEGORIES.get(record.importedName)
+ if (category) values.set(record.localName, new Set([category]))
+ }
+
+ const expressionCategories = (node) => {
+ if (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node)) {
+ return referenceCategories(node, values)
+ }
+ if (ts.isNewExpression(node)) return expressionCategories(node.expression)
+ if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) {
+ return mergeCategories([categoriesFor(node.type), expressionCategories(node.expression)])
+ }
+ if (ts.isParenthesizedExpression(node) || ts.isNonNullExpression(node)) {
+ return expressionCategories(node.expression)
+ }
+ return new Set()
+ }
+
+ const valueDeclarations = []
+ const collectValueDeclarations = (node) => {
+ if (
+ (ts.isVariableDeclaration(node) || ts.isParameter(node)) &&
+ ts.isIdentifier(node.name)
+ ) {
+ valueDeclarations.push(node)
+ values.set(node.name.text, new Set())
+ }
+ ts.forEachChild(node, collectValueDeclarations)
+ }
+ collectValueDeclarations(sourceFile)
+
+ changed = true
+ while (changed) {
+ changed = false
+ for (const declaration of valueDeclarations) {
+ const next = mergeCategories([
+ values.get(declaration.name.text),
+ declaration.type ? categoriesFor(declaration.type) : new Set(),
+ declaration.initializer ? expressionCategories(declaration.initializer) : new Set()
+ ])
+ const current = values.get(declaration.name.text)
+ if (!sameCategories(next, current)) {
+ values.set(declaration.name.text, next)
+ changed = true
+ }
+ }
+ }
+
+ const objectCategories = (object, declaredType) => {
+ const declaredProperties = new Map()
+ if (declaredType && ts.isTypeLiteralNode(declaredType)) {
+ for (const member of declaredType.members) {
+ const name = member.name && propertyNameText(member.name)
+ if (name && member.type) declaredProperties.set(name, categoriesFor(member.type))
+ }
+ }
+
+ const categories = new Set()
+ for (const property of object.properties) {
+ const name = property.name && propertyNameText(property.name)
+ const category = name && propertyCategory(name)
+ if (!category) continue
+
+ let evidence = new Set()
+ if (ts.isShorthandPropertyAssignment(property)) {
+ evidence = expressionCategories(property.name)
+ } else if (ts.isPropertyAssignment(property)) {
+ evidence = expressionCategories(property.initializer)
+ }
+ if (evidence.has(category) || declaredProperties.get(name)?.has(category)) {
+ categories.add(category)
+ }
+ }
+ return categories
+ }
+
+ for (const exported of exportedObjects) {
+ if (objectCategories(exported.object, exported.type).size === 4) {
+ facades.push(exported.name)
+ }
+ }
+ }
+
+ const visit = (node) => {
+ let name = null
+ let categories = new Set()
+ if (ts.isInterfaceDeclaration(node)) {
+ name = node.name.text
+ for (const child of [...(node.heritageClauses ?? []), ...node.members]) {
+ for (const category of categoriesFor(child)) categories.add(category)
+ }
+ } else if (ts.isTypeAliasDeclaration(node)) {
+ name = node.name.text
+ categories = capabilities.get(name)
+ } else if (ts.isClassDeclaration(node) && node.name) {
+ name = node.name.text
+ for (const child of node.heritageClauses ?? []) {
+ for (const category of categoriesFor(child)) categories.add(category)
+ }
+ }
+
+ if (name && categories.size === 4) facades.push(name)
+ ts.forEachChild(node, visit)
+ }
+ visit(sourceFile)
+ return facades
+}
+
+function isSessionPhaseOneForeignImport(value) {
+ const normalized = value.replaceAll(/([a-z\d])([A-Z])/g, '$1-$2').toLowerCase()
+ return SESSION_PHASE_ONE_FOREIGN_IMPORT_PATTERN.test(normalized)
+}
+
function findNamedClassDeclarations(sourceFile, className) {
const declarations = []
const visit = (node) => {
@@ -427,7 +925,7 @@ function newMapSignature(property, sourceFile) {
return initializer.typeArguments.map((node) => node.getText(sourceFile).replaceAll(/\s/g, ''))
}
-function analyzeMemoryRuntimeCoordinatorStructure(source, filePath) {
+export function analyzeMemoryRuntimeCoordinatorStructure(source, filePath) {
const sourceFile = sourceFileForAst(source, filePath)
const classes = findNamedClassDeclarations(sourceFile, 'MemoryRuntimeCoordinator')
if (classes.length === 0) {
@@ -1114,6 +1612,104 @@ export async function runArchitectureGuard({ virtualFiles = new Map(), memoryCom
for (const filePath of [...fileSet].sort()) {
const source = await readSource(filePath)
const specifiers = extractModuleSpecifiers(source)
+ const isMainSource = isUnder(filePath, MAIN_SOURCE_ROOT)
+
+ if (isMainSource) {
+ const sourceFile = sourceFileForAst(source, filePath)
+ const importRecords = importRecordsFromSourceFile(sourceFile)
+
+ if (isSessionMigratedConsumerPath(filePath)) {
+ const presenterDependencies = findIdentifierNames(
+ sourceFile,
+ new Set(['IAgentSessionPresenter', 'agentSessionPresenter'])
+ )
+ if (presenterDependencies.has('IAgentSessionPresenter')) {
+ violations.push(
+ `[session-consumer-presenter-type] ${relativePath(filePath)} must not use IAgentSessionPresenter`
+ )
+ }
+ if (presenterDependencies.has('agentSessionPresenter')) {
+ violations.push(
+ `[session-consumer-presenter-facade] ${relativePath(filePath)} must not use agentSessionPresenter`
+ )
+ }
+ }
+
+ const allowedOwnerConstructions =
+ path.resolve(filePath) === path.resolve(PRESENTER_ROOT_ENTRY) ? 1 : 0
+ for (const [owner, count] of findSessionApplicationOwnerConstructions(
+ sourceFile,
+ importRecords
+ )) {
+ if (count > allowedOwnerConstructions) {
+ violations.push(
+ `[session-application-duplicate-construction] ${relativePath(filePath)} constructs ${owner} ${count} times; expected <= ${allowedOwnerConstructions}`
+ )
+ }
+ }
+
+ const combinedFacades = findIdentifierNames(sourceFile, SESSION_COMBINED_FACADE_NAMES)
+ for (const facade of findCombinedSessionFacadeDeclarations(
+ sourceFile,
+ importRecords,
+ path.resolve(filePath) !== path.resolve(PRESENTER_ROOT_ENTRY)
+ )) {
+ combinedFacades.add(facade)
+ }
+ for (const facade of combinedFacades) {
+ violations.push(
+ `[session-application-combined-facade] ${relativePath(filePath)} contains ${facade}`
+ )
+ }
+
+ if (isSessionApplicationOwnerPath(filePath)) {
+ for (const specifier of specifiers) {
+ if (isSessionPhaseOneForeignImport(specifier)) {
+ violations.push(
+ `[session-coordinator-phase1-import] ${relativePath(filePath)} -> ${specifier}`
+ )
+ }
+ }
+ }
+
+ if (isUnder(filePath, SESSION_APPLICATION_ROOT)) {
+ const wholeDependencies = findIdentifierNames(
+ sourceFile,
+ SESSION_COORDINATOR_WHOLE_DEPENDENCY_NAMES
+ )
+
+ for (const specifier of new Set(importRecords.map((record) => record.specifier))) {
+ const aggregateRecords = importRecords.filter(
+ (record) =>
+ record.specifier === specifier &&
+ (record.importedName === 'default' ||
+ record.importedName === '*' ||
+ record.importedName === 'presenter')
+ )
+ if (aggregateRecords.length === 0) continue
+
+ const resolved = await resolveImport(
+ specifier,
+ filePath,
+ MAIN_SOURCE_ROOT,
+ normalizedVirtualFiles
+ )
+ if (!resolved) continue
+ if (path.resolve(resolved) === path.resolve(PRESENTER_ROOT_ENTRY)) {
+ wholeDependencies.add('Presenter')
+ }
+ if (isUnder(resolved, SQLITE_PRESENTER_ROOT)) {
+ wholeDependencies.add('SQLitePresenter')
+ }
+ }
+
+ for (const dependency of wholeDependencies) {
+ violations.push(
+ `[session-coordinator-whole-dependency] ${relativePath(filePath)} imports ${dependency}`
+ )
+ }
+ }
+ }
if (path.resolve(filePath) === path.resolve(AGENT_SESSION_PRESENTER_PATH)) {
const removedMethods = findNamedDeclarationMembers(
diff --git a/src/main/agent/shared/agentSessionNormalization.ts b/src/main/agent/shared/agentSessionNormalization.ts
new file mode 100644
index 0000000000..b1ea4ab1b3
--- /dev/null
+++ b/src/main/agent/shared/agentSessionNormalization.ts
@@ -0,0 +1,76 @@
+import type {
+ CreateSessionInput,
+ MessageFile,
+ SendMessageInput
+} from '@shared/types/agent-interface'
+
+const RETIRED_DEFAULT_AGENT_TOOLS = new Set(['find', 'ls'])
+const LEGACY_PERSISTED_DISABLED_AGENT_TOOLS = new Set(['find', 'grep', 'ls'])
+const LEGACY_AGENT_TOOL_NAME_MAP: Record = {
+ yo_browser_cdp_send: 'cdp_send',
+ yo_browser_window_open: 'load_url',
+ yo_browser_window_list: 'get_browser_status'
+}
+
+export const normalizeDisabledAgentTools = (
+ disabledAgentTools?: string[],
+ options?: { dropLegacySearchTools?: boolean }
+): string[] => {
+ if (!Array.isArray(disabledAgentTools)) return []
+ const retiredTools = options?.dropLegacySearchTools
+ ? LEGACY_PERSISTED_DISABLED_AGENT_TOOLS
+ : RETIRED_DEFAULT_AGENT_TOOLS
+
+ return Array.from(
+ new Set(
+ disabledAgentTools
+ .filter((item): item is string => typeof item === 'string')
+ .map((item) => item.trim())
+ .map((item) => LEGACY_AGENT_TOOL_NAME_MAP[item] ?? item)
+ .filter((item) => Boolean(item) && !retiredTools.has(item))
+ )
+ ).sort((left, right) => left.localeCompare(right))
+}
+
+export const normalizeActiveSkills = (activeSkills?: string[]): string[] => {
+ if (!Array.isArray(activeSkills)) return []
+ return Array.from(
+ new Set(
+ activeSkills
+ .filter((item): item is string => typeof item === 'string')
+ .map((item) => item.trim())
+ .filter(Boolean)
+ )
+ )
+}
+
+export const normalizeSendMessageInput = (content: string | SendMessageInput): SendMessageInput => {
+ if (typeof content === 'string') {
+ return { text: content, files: [] }
+ }
+
+ if (!content || typeof content !== 'object') {
+ return { text: '', files: [] }
+ }
+
+ const text = typeof content.text === 'string' ? content.text : ''
+ const files = Array.isArray(content.files)
+ ? content.files.filter((file): file is MessageFile => Boolean(file))
+ : []
+ const activeSkills = normalizeActiveSkills(content.activeSkills)
+ const inlineItems = Array.isArray(content.inlineItems) ? content.inlineItems : []
+ return {
+ text,
+ files,
+ ...(activeSkills.length > 0 ? { activeSkills } : {}),
+ ...(inlineItems.length > 0 ? { inlineItems } : {})
+ }
+}
+
+export const normalizeCreateSessionInput = (input: CreateSessionInput): SendMessageInput =>
+ normalizeSendMessageInput({
+ text: typeof input.message === 'string' ? input.message : '',
+ files: Array.isArray(input.files) ? input.files : [],
+ activeSkills: input.activeSkills,
+ inlineItems: Array.isArray(input.inlineItems) ? input.inlineItems : []
+ })
diff --git a/src/main/presenter/agentSessionPresenter/index.ts b/src/main/presenter/agentSessionPresenter/index.ts
index f209b96a28..7ef479931e 100644
--- a/src/main/presenter/agentSessionPresenter/index.ts
+++ b/src/main/presenter/agentSessionPresenter/index.ts
@@ -1,6 +1,3 @@
-import logger from '@shared/logger'
-import type { AgentManager } from '@/agent/manager/agentManager'
-import type { DirectAcpSessionHandle } from '@/agent/manager/sessionHandles'
import type {
AgentTapeAnchorResult,
AgentTapeAnchorsOptions,
@@ -9,29 +6,22 @@ import type {
AgentTapeInfo,
AgentTapeSearchOptions,
AgentTapeSearchResult,
- AgentTransferBlockReason,
AgentTransferImpact,
- AgentTransferImpactSample,
ChatMessagePageResult,
- SessionListItem,
- SessionLightweightListResult,
- SessionPageCursor,
- CreateSessionInput,
- CreateDetachedSessionInput,
- SessionRecord,
- SessionWithState,
ChatMessageRecord,
+ CreateDetachedSessionInput,
+ CreateSessionInput,
MessagePageCursor,
- MessageTraceRecord,
MessageStartResult,
- MessageFile,
- SendMessageInput,
- UserMessageContent,
- AssistantMessageBlock,
+ MessageTraceRecord,
PermissionMode,
+ SendMessageInput,
SessionCompactionState,
SessionGenerationSettings,
- DeepChatSubagentMeta,
+ SessionLightweightListResult,
+ SessionListItem,
+ SessionPageCursor,
+ SessionWithState,
ToolInteractionResponse,
ToolInteractionResult
} from '@shared/types/agent-interface'
@@ -41,433 +31,51 @@ import type {
DeepChatTapeReplayExportOptions,
DeepChatTapeReplaySlice
} from '@shared/types/tape-replay'
+import type { AcpConfigState } from '@shared/presenter'
+import { SessionProjectionCoordinator } from '../sessionApplication/projectionCoordinator'
import type {
- AcpConfigState,
- IConfigPresenter,
- ILlmProviderPresenter,
- ISkillPresenter
-} from '@shared/presenter'
-import type { SQLitePresenter } from '../sqlitePresenter'
-import { AppSessionService } from '@/agent/shared/appSessionService'
-import type { AgentSharedDataPorts } from '@/agent/shared/agentSharedData'
-import { toAppSessionId } from '@/agent/shared/agentSessionIds'
-import { publishDeepchatEvent } from '@/routes/publishDeepchatEvent'
-import { resolveAssistantModelSelection } from '@/agent/shared/assistantModelSelection'
-import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias'
-import type {
- AcpAsLlmProviderSessionControlPort,
- SessionPermissionPort,
- SessionUiPort
-} from '../runtimePorts'
-
-type AgentTransferTargetContext = {
- agentId: string
- agentType: 'deepchat'
- providerId: string
- modelId: string
- projectDir: string | null
- permissionMode: PermissionMode
- generationSettings?: Partial
- disabledAgentTools: string[]
- subagentEnabled: boolean
-}
-
-const SUBAGENT_SESSION_INIT_MAX_ATTEMPTS = 2
-function normalizePermissionMode(mode: PermissionMode | null | undefined): PermissionMode {
- return mode === 'default' || mode === 'auto_approve' ? mode : 'full_access'
-}
-
-const RETIRED_DEFAULT_AGENT_TOOLS = new Set(['find', 'ls'])
-const LEGACY_AGENT_TOOL_NAME_MAP: Record = {
- yo_browser_cdp_send: 'cdp_send',
- yo_browser_window_open: 'load_url',
- yo_browser_window_list: 'get_browser_status'
-}
+ SessionAgentAssignmentPort,
+ SessionLifecyclePort,
+ SessionLifecycleSubagentInput,
+ SessionTurnPort
+} from '../sessionApplication/ports'
+import type { SessionPermissionPort } from '../runtimePorts'
export class AgentSessionPresenter {
- private agentManager: AgentManager
- private sessionManager: AppSessionService
- private sqlitePresenter: SQLitePresenter
- private llmProviderPresenter: ILlmProviderPresenter
- private configPresenter: IConfigPresenter
- private sharedData: AgentSharedDataPorts
- private skillPresenter?: Pick
- private acpAsLlmProviderSessionControl?: AcpAsLlmProviderSessionControlPort
+ private sessionProjection: SessionProjectionCoordinator
+ private sessionLifecycle: SessionLifecyclePort
+ private sessionAssignment: SessionAgentAssignmentPort
+ private sessionTurn: SessionTurnPort
private sessionPermissionPort?: SessionPermissionPort
- private sessionUiPort?: SessionUiPort
- private readonly sessionStatusSnapshots = new Map()
constructor(
- agentManager: AgentManager,
- appSessionService: AppSessionService,
- llmProviderPresenter: ILlmProviderPresenter,
- configPresenter: IConfigPresenter,
- sqlitePresenter: SQLitePresenter,
- sharedData: AgentSharedDataPorts,
- skillPresenter?: Pick,
+ sessionProjection: SessionProjectionCoordinator,
+ sessionLifecycle: SessionLifecyclePort,
+ sessionAssignment: SessionAgentAssignmentPort,
+ sessionTurn: SessionTurnPort,
runtimePorts?: {
- acpAsLlmProviderSessionControl?: AcpAsLlmProviderSessionControlPort
sessionPermissionPort?: SessionPermissionPort
- sessionUiPort?: SessionUiPort
}
) {
- this.agentManager = agentManager
- this.sqlitePresenter = sqlitePresenter
- this.llmProviderPresenter = llmProviderPresenter
- this.configPresenter = configPresenter
- this.sharedData = sharedData
- this.skillPresenter = skillPresenter
- this.sessionManager = appSessionService
- this.acpAsLlmProviderSessionControl = runtimePorts?.acpAsLlmProviderSessionControl
+ this.sessionProjection = sessionProjection
+ this.sessionLifecycle = sessionLifecycle
+ this.sessionAssignment = sessionAssignment
+ this.sessionTurn = sessionTurn
this.sessionPermissionPort = runtimePorts?.sessionPermissionPort
- this.sessionUiPort = runtimePorts?.sessionUiPort
}
// ---- IPC-facing methods ----
async createSession(input: CreateSessionInput, webContentsId: number): Promise {
- const requestedAgentId = input.agentId || 'deepchat'
- const resolvedAgent = this.agentManager.resolveBackend(requestedAgentId)
- const agentId = resolvedAgent.descriptor.id
- logger.info(
- `[AgentSessionPresenter] createSession agent=${agentId} webContentsId=${webContentsId}`
- )
- const normalizedInput = this.normalizeCreateSessionInput(input)
- const agentType = resolvedAgent.kind
- const deepChatAgentConfig =
- agentType === 'deepchat' ? await this.resolveDeepChatAgentConfigCompat(agentId) : null
- const projectDir = this.resolveCreateSessionProjectDir(
- input.projectDir,
- deepChatAgentConfig?.defaultProjectPath
- )
- const disabledAgentTools =
- agentType === 'deepchat'
- ? this.normalizeDisabledAgentTools(
- input.disabledAgentTools ?? deepChatAgentConfig?.disabledAgentTools
- )
- : []
- const subagentEnabled = this.resolveSessionSubagentEnabled(
- agentType,
- input.subagentEnabled,
- deepChatAgentConfig?.subagentEnabled
- )
-
- // Resolve provider/model
- const defaultModel = this.configPresenter.getDefaultModel()
- const providerId =
- agentType === 'acp'
- ? 'acp'
- : (input.providerId ??
- deepChatAgentConfig?.defaultModelPreset?.providerId ??
- defaultModel?.providerId ??
- '')
- const modelId =
- agentType === 'acp'
- ? agentId
- : (input.modelId ??
- deepChatAgentConfig?.defaultModelPreset?.modelId ??
- defaultModel?.modelId ??
- '')
- const permissionMode =
- input.permissionMode !== undefined
- ? normalizePermissionMode(input.permissionMode)
- : normalizePermissionMode(deepChatAgentConfig?.permissionMode)
- const generationSettings = this.mergeDeepChatDefaultGenerationSettings(
- deepChatAgentConfig,
- input.generationSettings
- )
- logger.info(`[AgentSessionPresenter] resolved provider=${providerId} model=${modelId}`)
-
- if (!providerId || !modelId) {
- throw new Error('No provider or model configured. Please set a default model in settings.')
- }
- this.assertAcpSessionHasWorkdir(providerId, projectDir)
-
- // Create session record
- const title = normalizedInput.text.slice(0, 50) || 'New Chat'
- const sessionId = this.sessionManager.create(agentId, title, projectDir, {
- isDraft: false,
- disabledAgentTools,
- subagentEnabled
- })
- logger.info(`[AgentSessionPresenter] session created id=${sessionId} title="${title}"`)
-
- // Initialize agent-side session
- const initConfig: {
- agentId?: string
- providerId: string
- modelId: string
- projectDir: string | null
- permissionMode: PermissionMode
- generationSettings?: Partial
- } = {
- agentId,
- providerId,
- modelId,
- projectDir,
- permissionMode
- }
- if (generationSettings) {
- initConfig.generationSettings = generationSettings
- }
- try {
- await this.initializeSessionRuntime(sessionId, initConfig)
- } catch (error) {
- await this.cleanupFailedSessionInitialization(sessionId, providerId)
- throw error
- }
- logger.info(`[AgentSessionPresenter] agent.initSession done`)
-
- // Bind to window and emit activated
- this.sessionManager.bindWindow(webContentsId, toAppSessionId(sessionId))
- this.emitSessionListUpdated({
- sessionIds: [sessionId],
- reason: 'created',
- activeSessionId: sessionId,
- webContentsId
- })
-
- // Return enriched session first
- const { handle } = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId))
- const state = await handle.snapshot()
- const sessionResult: SessionWithState = {
- id: sessionId,
- agentId,
- title,
- projectDir,
- isPinned: false,
- isDraft: false,
- sessionKind: 'regular',
- parentSessionId: null,
- subagentEnabled,
- subagentMeta: null,
- createdAt: Date.now(),
- updatedAt: Date.now(),
- status: state?.status ?? 'idle',
- providerId: state?.providerId ?? providerId,
- modelId: state?.modelId ?? modelId
- }
-
- // Start the first message (non-blocking) after returning session ID.
- const hasInitialTurn =
- normalizedInput.text.trim().length > 0 || (normalizedInput.files?.length ?? 0) > 0
- if (hasInitialTurn) {
- logger.info(`[AgentSessionPresenter] firing initial send (non-blocking)`)
- handle
- .send({
- content: this.withInitialMessageActiveSkills(normalizedInput, input.activeSkills),
- context: { projectDir },
- queue: { source: 'send', projectDir }
- })
- .catch((err) => {
- console.error('[AgentSessionPresenter] initial send failed:', err)
- })
- void this.generateSessionTitle(sessionId, title, providerId, modelId)
- }
-
- return sessionResult
+ return await this.sessionLifecycle.createSession(input, webContentsId)
}
async createDetachedSession(input: CreateDetachedSessionInput): Promise {
- const requestedAgentId = input.agentId?.trim() || 'deepchat'
- const resolvedAgent = this.agentManager.resolveBackend(requestedAgentId)
- const agentId = resolvedAgent.descriptor.id
- const title = input.title?.trim() || 'New Chat'
- const agentType = resolvedAgent.kind
- const deepChatAgentConfig =
- agentType === 'deepchat' ? await this.resolveDeepChatAgentConfigCompat(agentId) : null
- const projectDir =
- input.projectDir?.trim() ||
- deepChatAgentConfig?.defaultProjectPath?.trim() ||
- this.getDefaultProjectPathCompat() ||
- null
- const disabledAgentTools =
- agentType === 'deepchat'
- ? this.normalizeDisabledAgentTools(
- input.disabledAgentTools ?? deepChatAgentConfig?.disabledAgentTools
- )
- : []
- const subagentEnabled = this.resolveSessionSubagentEnabled(
- agentType,
- input.subagentEnabled,
- deepChatAgentConfig?.subagentEnabled
- )
- const defaultModel = this.configPresenter.getDefaultModel()
- const providerId =
- agentType === 'acp'
- ? 'acp'
- : (input.providerId ??
- deepChatAgentConfig?.defaultModelPreset?.providerId ??
- defaultModel?.providerId ??
- '')
- const modelId =
- agentType === 'acp'
- ? agentId
- : (input.modelId ??
- deepChatAgentConfig?.defaultModelPreset?.modelId ??
- defaultModel?.modelId ??
- '')
- const permissionMode =
- input.permissionMode !== undefined
- ? normalizePermissionMode(input.permissionMode)
- : normalizePermissionMode(deepChatAgentConfig?.permissionMode)
- const generationSettings = this.mergeDeepChatDefaultGenerationSettings(
- deepChatAgentConfig,
- input.generationSettings
- )
-
- if (!providerId || !modelId) {
- throw new Error('No provider or model configured. Please set a default model in settings.')
- }
- this.assertAcpSessionHasWorkdir(providerId, projectDir)
-
- const sessionId = this.sessionManager.create(agentId, title, projectDir, {
- isDraft: false,
- disabledAgentTools,
- subagentEnabled,
- metadata: input.metadata ?? null
- })
-
- try {
- await this.initializeSessionRuntime(sessionId, {
- agentId,
- providerId,
- modelId,
- projectDir,
- permissionMode,
- generationSettings
- })
- } catch (error) {
- await this.cleanupFailedSessionInitialization(sessionId, providerId)
- throw error
- }
-
- if (input.activeSkills && input.activeSkills.length > 0 && this.skillPresenter) {
- await this.skillPresenter.setActiveSkills(sessionId, input.activeSkills)
- }
-
- this.emitSessionListUpdated({
- sessionIds: [sessionId],
- reason: 'created'
- })
-
- const state = await this.agentManager
- .resolveSessionHandle(toAppSessionId(sessionId))
- .handle.snapshot()
- return {
- id: sessionId,
- agentId,
- title,
- projectDir,
- isPinned: false,
- isDraft: false,
- sessionKind: 'regular',
- parentSessionId: null,
- subagentEnabled,
- subagentMeta: null,
- createdAt: Date.now(),
- updatedAt: Date.now(),
- ...(input.metadata ? { metadata: input.metadata } : {}),
- status: state?.status ?? 'idle',
- providerId: state?.providerId ?? providerId,
- modelId: state?.modelId ?? modelId
- }
+ return await this.sessionLifecycle.createDetachedSession(input)
}
- async createSubagentSession(input: {
- parentSessionId: string
- agentId: string
- slotId: string
- displayName: string
- targetAgentId?: string | null
- projectDir?: string | null
- providerId: string
- modelId: string
- permissionMode: PermissionMode
- generationSettings?: Partial
- disabledAgentTools?: string[]
- activeSkills?: string[]
- }): Promise {
- const parentSessionId = input.parentSessionId?.trim()
- if (!parentSessionId) {
- throw new Error('Subagent session requires a parentSessionId.')
- }
-
- const slotId = input.slotId?.trim()
- if (!slotId) {
- throw new Error('Subagent session requires a slotId.')
- }
-
- const displayName = input.displayName?.trim() || 'Subagent'
- const agentId = input.agentId?.trim()
- if (!agentId) {
- throw new Error('Subagent session requires an agentId.')
- }
-
- const runtimeConfig = await this.resolveSubagentSessionRuntimeConfig(input)
- const projectDir = input.projectDir?.trim() || null
- const subagentMeta: DeepChatSubagentMeta = {
- slotId,
- displayName,
- targetAgentId: runtimeConfig.targetAgentId || null
- }
- this.assertAcpSessionHasWorkdir(runtimeConfig.providerId, projectDir)
-
- let lastError: unknown = null
-
- for (let attempt = 1; attempt <= SUBAGENT_SESSION_INIT_MAX_ATTEMPTS; attempt += 1) {
- const sessionId = this.sessionManager.create(runtimeConfig.agentId, displayName, projectDir, {
- isDraft: false,
- disabledAgentTools: runtimeConfig.disabledAgentTools,
- subagentEnabled: false,
- sessionKind: 'subagent',
- parentSessionId,
- subagentMeta
- })
-
- try {
- await this.initializeSessionRuntime(sessionId, {
- agentId: runtimeConfig.agentId,
- providerId: runtimeConfig.providerId,
- modelId: runtimeConfig.modelId,
- projectDir,
- permissionMode: input.permissionMode,
- generationSettings: runtimeConfig.generationSettings
- })
-
- if (runtimeConfig.activeSkills.length > 0 && this.skillPresenter) {
- await this.skillPresenter.setActiveSkills(sessionId, runtimeConfig.activeSkills)
- }
-
- const record = this.sessionManager.get(sessionId)
- if (!record) {
- throw new Error(`Subagent session not found after creation: ${sessionId}`)
- }
-
- const session = (await this.buildSessionWithState(record)) as SessionWithState
- this.emitSessionListUpdated({
- sessionIds: [session.id],
- reason: 'created'
- })
- return session
- } catch (error) {
- lastError = error
- await this.cleanupFailedSessionInitialization(sessionId, runtimeConfig.providerId)
-
- if (attempt >= SUBAGENT_SESSION_INIT_MAX_ATTEMPTS) {
- throw error
- }
-
- console.warn(
- `[AgentSessionPresenter] Retrying subagent session initialization (${attempt}/${SUBAGENT_SESSION_INIT_MAX_ATTEMPTS - 1} retry used) for agent=${runtimeConfig.agentId} slot=${slotId}:`,
- error
- )
- }
- }
-
- throw lastError instanceof Error
- ? lastError
- : new Error(`Failed to create subagent session for slot ${slotId}.`)
+ async createSubagentSession(input: SessionLifecycleSubagentInput): Promise {
+ return await this.sessionLifecycle.createSubagentSession(input)
}
async ensureAcpDraftSession(input: {
@@ -475,73 +83,7 @@ export class AgentSessionPresenter {
projectDir: string
permissionMode?: PermissionMode
}): Promise {
- const agentId = input.agentId?.trim()
- if (!agentId) {
- throw new Error('ACP draft session requires an agentId.')
- }
-
- const projectDir = input.projectDir?.trim()
- if (!projectDir) {
- throw new Error('ACP draft session requires a non-empty projectDir.')
- }
-
- const resolvedAgent = this.agentManager.resolveBackend(agentId)
- if (resolvedAgent.kind !== 'acp') {
- throw new Error(`Agent ${agentId} is not an ACP agent.`)
- }
- const canonicalAgentId = resolvedAgent.descriptor.id
- const permissionMode = normalizePermissionMode(input.permissionMode)
-
- let record = await this.findReusableDraftSession(canonicalAgentId, projectDir)
- let createdDraftSession = false
- if (!record) {
- const sessionId = this.sessionManager.create(canonicalAgentId, 'New Chat', projectDir, {
- isDraft: true,
- subagentEnabled: false
- })
- try {
- await this.ensureSessionRuntimeInitialized(sessionId, {
- agentId: canonicalAgentId,
- providerId: 'acp',
- modelId: canonicalAgentId,
- projectDir,
- permissionMode
- })
- } catch (error) {
- await this.cleanupFailedSessionInitialization(sessionId, 'acp')
- throw error
- }
- record = this.sessionManager.get(sessionId)
- if (!record) {
- throw new Error(`Failed to read created ACP draft session: ${sessionId}`)
- }
- createdDraftSession = true
- } else {
- await this.ensureSessionRuntimeInitialized(record.id, {
- agentId: canonicalAgentId,
- providerId: 'acp',
- modelId: canonicalAgentId,
- projectDir,
- permissionMode
- })
- }
-
- const handle = this.requireDirectAcpHandle(record.id)
- await handle.acp.prepare()
- this.emitSessionListUpdated({
- sessionIds: [record.id],
- reason: createdDraftSession ? 'created' : 'updated'
- })
-
- const state = await this.agentManager
- .resolveSessionHandle(toAppSessionId(record.id))
- .handle.snapshot()
- return {
- ...record,
- status: state?.status ?? 'idle',
- providerId: state?.providerId ?? 'acp',
- modelId: state?.modelId ?? canonicalAgentId
- }
+ return await this.sessionLifecycle.ensureAcpDraftSession(input)
}
async sendMessage(
@@ -549,199 +91,47 @@ export class AgentSessionPresenter {
content: string | SendMessageInput,
options?: { maxProviderRounds?: number }
): Promise {
- let session = this.sessionManager.get(sessionId)
- if (!session) throw new Error(`Session not found: ${sessionId}`)
- const wasDraft = session.isDraft
- const normalizedInput = this.normalizeSendMessageInput(content)
-
- if (session.isDraft) {
- const title = normalizedInput.text.trim().slice(0, 50) || 'New Chat'
- this.sessionManager.update(sessionId, { isDraft: false, title })
- this.emitSessionListUpdated({
- sessionIds: [sessionId],
- reason: 'updated'
- })
- session = this.sessionManager.get(sessionId)
- if (!session) throw new Error(`Session not found: ${sessionId}`)
- }
-
- const { handle } = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId))
- const state = await handle.snapshot()
- const hadMessages = await this.sharedData.transcript.hasMessages(sessionId)
- let providerId = state?.providerId ?? ''
- if (!providerId && handle.kind === 'acp') providerId = 'acp'
- this.assertAcpSessionHasWorkdir(providerId, session.projectDir ?? null)
- await this.syncAcpSessionWorkdir(
- providerId,
- sessionId,
- session.agentId,
- session.projectDir ?? null
- )
- const result = await handle.send({
- content: normalizedInput,
- context: {
- projectDir: session.projectDir ?? null,
- maxProviderRounds: options?.maxProviderRounds
- },
- queue: {
- source: 'send',
- projectDir: session.projectDir ?? null
- }
- })
- if (!hadMessages && !wasDraft) {
- void this.generateSessionTitle(sessionId, session.title, providerId, state?.modelId ?? '')
- }
- return result
+ return await this.sessionTurn.sendMessage(sessionId, content, options)
}
async steerActiveTurn(sessionId: string, content: string | SendMessageInput): Promise {
- let session = this.sessionManager.get(sessionId)
- if (!session) throw new Error(`Session not found: ${sessionId}`)
- const normalizedInput = this.normalizeSendMessageInput(content)
-
- if (session.isDraft) {
- const title = normalizedInput.text.trim().slice(0, 50) || 'New Chat'
- this.sessionManager.update(sessionId, { isDraft: false, title })
- this.emitSessionListUpdated({
- sessionIds: [sessionId],
- reason: 'updated'
- })
- session = this.sessionManager.get(sessionId)
- if (!session) throw new Error(`Session not found: ${sessionId}`)
- }
-
- const { handle } = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId))
- const state = await handle.snapshot()
- let providerId = state?.providerId ?? ''
- if (!providerId && handle.kind === 'acp') providerId = 'acp'
- this.assertAcpSessionHasWorkdir(providerId, session.projectDir ?? null)
- await this.syncAcpSessionWorkdir(
- providerId,
- sessionId,
- session.agentId,
- session.projectDir ?? null
- )
-
- await handle.pending.steerActiveTurn(normalizedInput)
+ await this.sessionTurn.steerActiveTurn(sessionId, content)
}
async listPendingInputs(sessionId: string) {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- return []
- }
- return await this.agentManager
- .resolveSessionHandle(toAppSessionId(sessionId))
- .handle.pending.list()
+ return await this.sessionTurn.listPendingInputs(sessionId)
}
async queuePendingInput(sessionId: string, content: string | SendMessageInput) {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
-
- let currentSession = session
- const normalizedInput = this.normalizeSendMessageInput(content)
- if (currentSession.isDraft) {
- const title = normalizedInput.text.trim().slice(0, 50) || 'New Chat'
- this.sessionManager.update(sessionId, { isDraft: false, title })
- this.emitSessionListUpdated({
- sessionIds: [sessionId],
- reason: 'updated'
- })
- currentSession = this.sessionManager.get(sessionId) ?? currentSession
- }
-
- const { handle } = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId))
- let providerId = (await handle.snapshot())?.providerId ?? ''
- if (!providerId && handle.kind === 'acp') providerId = 'acp'
- this.assertAcpSessionHasWorkdir(providerId, currentSession.projectDir ?? null)
- await this.syncAcpSessionWorkdir(
- providerId,
- sessionId,
- currentSession.agentId,
- currentSession.projectDir ?? null
- )
- return await handle.pending.queue(normalizedInput, {
- source: 'queue',
- projectDir: currentSession.projectDir ?? null
- })
+ return await this.sessionTurn.queuePendingInput(sessionId, content)
}
async updateQueuedInput(sessionId: string, itemId: string, content: string | SendMessageInput) {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
- return await this.agentManager
- .resolveSessionHandle(toAppSessionId(sessionId))
- .handle.pending.update(itemId, this.normalizeSendMessageInput(content))
+ return await this.sessionTurn.updateQueuedInput(sessionId, itemId, content)
}
async moveQueuedInput(sessionId: string, itemId: string, toIndex: number) {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
- return await this.agentManager
- .resolveSessionHandle(toAppSessionId(sessionId))
- .handle.pending.move(itemId, toIndex)
+ return await this.sessionTurn.moveQueuedInput(sessionId, itemId, toIndex)
}
async convertPendingInputToSteer(sessionId: string, itemId: string) {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
- return await this.agentManager
- .resolveSessionHandle(toAppSessionId(sessionId))
- .handle.pending.convertToSteer(itemId)
+ return await this.sessionTurn.convertPendingInputToSteer(sessionId, itemId)
}
async steerPendingInput(sessionId: string, itemId: string) {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
- return await this.agentManager
- .resolveSessionHandle(toAppSessionId(sessionId))
- .handle.pending.steer(itemId)
+ return await this.sessionTurn.steerPendingInput(sessionId, itemId)
}
async deletePendingInput(sessionId: string, itemId: string): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
- await this.agentManager
- .resolveSessionHandle(toAppSessionId(sessionId))
- .handle.pending.delete(itemId)
+ await this.sessionTurn.deletePendingInput(sessionId, itemId)
}
async retryMessage(sessionId: string, messageId: string): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
- const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle
- const prepared = await this.sharedData.transcriptMutation.prepareRetryMessage(
- sessionId,
- messageId
- )
- await handle.send({
- content: prepared.content,
- context: { projectDir: prepared.projectDir, emitRefreshBeforeStream: true }
- })
+ await this.sessionTurn.retryMessage(sessionId, messageId)
}
async deleteMessage(sessionId: string, messageId: string): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
- await this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle.cancel()
- await this.sharedData.transcriptMutation.deleteMessage(sessionId, messageId)
+ await this.sessionTurn.deleteMessage(sessionId, messageId)
}
async editUserMessage(
@@ -749,11 +139,7 @@ export class AgentSessionPresenter {
messageId: string,
text: string
): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
- return await this.sharedData.transcriptMutation.editUserMessage(sessionId, messageId, text)
+ return await this.sessionTurn.editUserMessage(sessionId, messageId, text)
}
async forkSession(
@@ -761,75 +147,7 @@ export class AgentSessionPresenter {
targetMessageId: string,
newTitle?: string
): Promise {
- const sourceSession = this.sessionManager.get(sourceSessionId)
- if (!sourceSession) {
- throw new Error(`Session not found: ${sourceSessionId}`)
- }
-
- const sourceHandle = this.agentManager.resolveSessionHandle(
- toAppSessionId(sourceSessionId)
- ).handle
- const sourceState = await sourceHandle.snapshot()
- if (!sourceState) {
- throw new Error(`Session state not found: ${sourceSessionId}`)
- }
-
- const generationSettings = await sourceHandle.settings.getGenerationSettings()
-
- const title = this.buildForkTitle(sourceSession.title, newTitle)
- const targetSessionId = this.sessionManager.create(
- sourceSession.agentId,
- title,
- sourceSession.projectDir ?? null,
- { isDraft: false }
- )
-
- try {
- await this.initializeSessionRuntime(targetSessionId, {
- agentId: sourceSession.agentId,
- providerId: sourceState.providerId,
- modelId: sourceState.modelId,
- projectDir: sourceSession.projectDir ?? null,
- permissionMode: sourceState.permissionMode,
- generationSettings: generationSettings ?? undefined
- })
- await this.sharedData.transcriptMutation.forkSessionFromMessage(
- sourceSessionId,
- targetSessionId,
- targetMessageId
- )
- } catch (error) {
- try {
- await this.agentManager.resolveSessionHandle(toAppSessionId(targetSessionId)).handle.close()
- } catch (cleanupError) {
- console.warn(
- `[AgentSessionPresenter] Failed to cleanup forked session runtime ${targetSessionId}:`,
- cleanupError
- )
- }
- this.sessionManager.delete(targetSessionId)
- throw error
- }
-
- this.emitSessionListUpdated({
- sessionIds: [targetSessionId],
- reason: 'created'
- })
-
- const record = this.sessionManager.get(targetSessionId)
- if (!record) {
- throw new Error(`Forked session not found: ${targetSessionId}`)
- }
-
- const targetState = await this.agentManager
- .resolveSessionHandle(toAppSessionId(targetSessionId))
- .handle.snapshot()
- return {
- ...record,
- status: targetState?.status ?? 'idle',
- providerId: targetState?.providerId ?? sourceState.providerId,
- modelId: targetState?.modelId ?? sourceState.modelId
- }
+ return await this.sessionLifecycle.forkSession(sourceSessionId, targetMessageId, newTitle)
}
async getSessionList(filters?: {
@@ -838,17 +156,7 @@ export class AgentSessionPresenter {
includeSubagents?: boolean
parentSessionId?: string
}): Promise {
- const records = this.sessionManager.list(filters)
- const enriched: SessionWithState[] = []
-
- for (const record of records) {
- const session = await this.tryBuildSessionWithState(record, 'list')
- if (session) {
- enriched.push(session)
- }
- }
-
- return enriched
+ return await this.sessionProjection.listSessions(filters)
}
async getLightweightSessionList(options?: {
@@ -858,51 +166,19 @@ export class AgentSessionPresenter {
agentId?: string
prioritizeSessionId?: string
}): Promise {
- const page = this.sessionManager.listPage({
- limit: options?.limit,
- cursor: options?.cursor,
- agentId: options?.agentId,
- includeSubagents: options?.includeSubagents
- })
- const items = page.records.map((record) => this.mapSessionRecordToListItem(record))
-
- const prioritizeSessionId = options?.prioritizeSessionId?.trim()
- if (prioritizeSessionId) {
- const prioritizedRecord = this.sessionManager.get(prioritizeSessionId)
- if (prioritizedRecord && this.matchesLightweightFilter(prioritizedRecord, options)) {
- items.unshift(this.mapSessionRecordToListItem(prioritizedRecord))
- }
- }
-
- const deduped = this.dedupeAndSortSessionListItems(items)
- return {
- items: deduped,
- nextCursor: page.nextCursor,
- hasMore: page.hasMore
- }
+ return await this.sessionProjection.listLightweight(options)
}
async getLightweightSessionsByIds(sessionIds: string[]): Promise {
- const dedupedIds = Array.from(
- new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean))
- )
- return this.dedupeAndSortSessionListItems(
- this.sessionManager
- .getMany(dedupedIds)
- .map((record) => this.mapSessionRecordToListItem(record))
- )
+ return await this.sessionProjection.getLightweightByIds(sessionIds)
}
async getSession(sessionId: string): Promise {
- const record = this.sessionManager.get(sessionId)
- if (!record) return null
- return await this.tryBuildSessionWithState(record)
+ return await this.sessionProjection.getSession(sessionId)
}
async getMessages(sessionId: string): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) throw new Error(`Session not found: ${sessionId}`)
- return await this.sharedData.transcript.getMessages(sessionId)
+ return await this.sessionProjection.getMessages(sessionId)
}
async listMessagesPage(
@@ -912,60 +188,21 @@ export class AgentSessionPresenter {
cursor?: MessagePageCursor | null
}
): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
-
- return await this.sharedData.transcript.listMessagesPage(sessionId, options)
+ return await this.sessionProjection.listMessagesPage(sessionId, options)
}
async getSessionCompactionState(sessionId: string): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
-
- const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle
- if (handle.kind !== 'deepchat') {
- return {
- status: 'idle',
- cursorOrderSeq: 1,
- summaryUpdatedAt: null
- }
- }
-
- return await handle.deepchat.getCompactionState()
+ return await this.sessionTurn.getSessionCompactionState(sessionId)
}
async compactSession(
sessionId: string
): Promise<{ compacted: boolean; state: SessionCompactionState }> {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
-
- const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle
- if (handle.kind !== 'deepchat') {
- throw new Error(`Agent ${session.agentId} does not support manual compaction.`)
- }
-
- const state = await handle.snapshot()
- if (state?.providerId === 'acp') {
- throw new Error('Manual compaction is only available for DeepChat agent sessions.')
- }
-
- return await handle.deepchat.compact()
+ return await this.sessionTurn.compactSession(sessionId)
}
async getTapeInfo(sessionId: string): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
-
- return await this.sharedData.tape.getTapeInfo(sessionId)
+ return await this.sessionProjection.getTapeInfo(sessionId)
}
async searchTape(
@@ -973,12 +210,7 @@ export class AgentSessionPresenter {
query: string,
options?: AgentTapeSearchOptions
): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
-
- return await this.sharedData.tape.searchTape(sessionId, query, options)
+ return await this.sessionProjection.searchTape(sessionId, query, options)
}
async getTapeContext(
@@ -986,24 +218,14 @@ export class AgentSessionPresenter {
entryIds: number[],
options?: AgentTapeContextOptions
): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
-
- return await this.sharedData.tape.getTapeContext(sessionId, entryIds, options)
+ return await this.sessionProjection.getTapeContext(sessionId, entryIds, options)
}
async listTapeAnchors(
sessionId: string,
options?: AgentTapeAnchorsOptions
): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
-
- return await this.sharedData.tape.listTapeAnchors(sessionId, options)
+ return await this.sessionProjection.listTapeAnchors(sessionId, options)
}
async handoffTape(
@@ -1011,64 +233,18 @@ export class AgentSessionPresenter {
name: string,
state: Record = {}
): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
-
- return await this.sharedData.tape.handoffTape(sessionId, name, state)
+ return await this.sessionProjection.handoffTape(sessionId, name, state)
}
async listMessageViewManifests(messageId: string): Promise {
- const normalizedMessageId = messageId?.trim()
- if (!normalizedMessageId) return []
-
- const message = this.sqlitePresenter.deepchatMessagesTable.get(normalizedMessageId)
- if (!message) return []
-
- const session = this.sessionManager.get(message.session_id)
- if (!session) return []
-
- try {
- return await this.sharedData.tape.listMessageViewManifests(
- message.session_id,
- normalizedMessageId
- )
- } catch (error) {
- logger.warn('[AgentSessionPresenter] Failed to list message view manifests', {
- messageId: normalizedMessageId,
- error
- })
- return []
- }
+ return await this.sessionProjection.listMessageViewManifests(messageId)
}
async exportMessageTapeReplaySlice(
messageId: string,
options?: DeepChatTapeReplayExportOptions
): Promise {
- const normalizedMessageId = messageId?.trim()
- if (!normalizedMessageId) return null
-
- const message = this.sqlitePresenter.deepchatMessagesTable.get(normalizedMessageId)
- if (!message) return null
-
- const session = this.sessionManager.get(message.session_id)
- if (!session) return null
-
- try {
- return await this.sharedData.tape.exportMessageTapeReplaySlice(
- message.session_id,
- normalizedMessageId,
- options
- )
- } catch (error) {
- logger.warn('[AgentSessionPresenter] Failed to export tape replay slice', {
- messageId: normalizedMessageId,
- error
- })
- return null
- }
+ return await this.sessionProjection.exportMessageTapeReplaySlice(messageId, options)
}
async mergeSubagentTape(
@@ -1076,36 +252,7 @@ export class AgentSessionPresenter {
childSessionId: string,
meta: Record = {}
): Promise {
- const parentSession = this.sessionManager.get(parentSessionId)
- if (!parentSession) {
- throw new Error(`Session not found: ${parentSessionId}`)
- }
-
- const childSession = this.sessionManager.get(childSessionId)
- if (!childSession) {
- throw new Error(`Session not found: ${childSessionId}`)
- }
- if (childSession.parentSessionId !== parentSessionId) {
- throw new Error(`Session ${childSessionId} is not a child of ${parentSessionId}.`)
- }
-
- const resolved = this.agentManager.resolveSubagentFacet(toAppSessionId(parentSessionId))
- switch (resolved.kind) {
- case 'deepchat':
- await resolved.facet.mergeTape(
- toAppSessionId(parentSessionId),
- toAppSessionId(childSessionId),
- meta
- )
- break
- case 'acp':
- await resolved.facet.mergeTape(
- toAppSessionId(parentSessionId),
- toAppSessionId(childSessionId),
- meta
- )
- break
- }
+ await this.sessionAssignment.mergeSubagentTape(parentSessionId, childSessionId, meta)
}
async discardSubagentTape(
@@ -1113,400 +260,82 @@ export class AgentSessionPresenter {
childSessionId: string,
meta: Record = {}
): Promise {
- const parentSession = this.sessionManager.get(parentSessionId)
- if (!parentSession) {
- throw new Error(`Session not found: ${parentSessionId}`)
- }
-
- const childSession = this.sessionManager.get(childSessionId)
- if (!childSession) {
- throw new Error(`Session not found: ${childSessionId}`)
- }
- if (childSession.parentSessionId !== parentSessionId) {
- throw new Error(`Session ${childSessionId} is not a child of ${parentSessionId}.`)
- }
-
- const resolved = this.agentManager.resolveSubagentFacet(toAppSessionId(parentSessionId))
- switch (resolved.kind) {
- case 'deepchat':
- await resolved.facet.discardTape(
- toAppSessionId(parentSessionId),
- toAppSessionId(childSessionId),
- meta
- )
- break
- case 'acp':
- await resolved.facet.discardTape(
- toAppSessionId(parentSessionId),
- toAppSessionId(childSessionId),
- meta
- )
- break
- }
+ await this.sessionAssignment.discardSubagentTape(parentSessionId, childSessionId, meta)
}
async getSearchResults(messageId: string, searchId?: string): Promise {
- const normalizedMessageId = messageId?.trim()
- if (!normalizedMessageId) {
- return []
- }
- const parsed: SearchResult[] = []
- const rows =
- this.sqlitePresenter.deepchatMessageSearchResultsTable.listByMessageId(normalizedMessageId)
- for (const row of rows) {
- try {
- const result = JSON.parse(row.content) as SearchResult
- parsed.push({
- ...result,
- rank: typeof result.rank === 'number' ? result.rank : (row.rank ?? undefined),
- searchId: result.searchId ?? row.search_id ?? undefined
- })
- } catch (error) {
- console.warn('[AgentSessionPresenter] Failed to parse search result row:', error)
- }
- }
-
- if (searchId) {
- const filtered = parsed.filter((item) => item.searchId === searchId)
- if (filtered.length > 0) {
- return filtered
- }
- const legacy = parsed.filter((item) => !item.searchId)
- if (legacy.length > 0) {
- return legacy
- }
- }
-
- return parsed
+ return await this.sessionProjection.getSearchResults(messageId, searchId)
}
async listMessageTraces(messageId: string): Promise {
- if (!messageId?.trim()) return []
- return this.sqlitePresenter.deepchatMessageTracesTable
- .listByMessageId(messageId)
- .map((row) => ({
- id: row.id,
- messageId: row.message_id,
- sessionId: row.session_id,
- providerId: row.provider_id,
- modelId: row.model_id,
- requestSeq: row.request_seq,
- endpoint: row.endpoint,
- headersJson: row.headers_json,
- bodyJson: row.body_json,
- truncated: row.truncated === 1,
- createdAt: row.created_at
- }))
+ return await this.sessionProjection.listMessageTraces(messageId)
}
async getMessageTraceCount(messageId: string): Promise {
- const normalizedMessageId = messageId?.trim()
- if (!normalizedMessageId) return 0
- return this.sqlitePresenter.deepchatMessageTracesTable.countByMessageId(normalizedMessageId)
+ return await this.sessionProjection.getMessageTraceCount(messageId)
}
async getMessageIds(sessionId: string): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) throw new Error(`Session not found: ${sessionId}`)
- return await this.sharedData.transcript.getMessageIds(sessionId)
+ return await this.sessionProjection.getMessageIds(sessionId)
}
async getMessage(messageId: string): Promise {
- return await this.sharedData.transcript.getMessage(messageId)
+ return await this.sessionProjection.getMessage(messageId)
}
async activateSession(webContentsId: number, sessionId: string): Promise {
- this.sessionManager.bindWindow(webContentsId, toAppSessionId(sessionId))
- publishDeepchatEvent('sessions.updated', {
- sessionIds: [sessionId],
- reason: 'activated',
- activeSessionId: sessionId,
- webContentsId
- })
+ await this.sessionProjection.activate(webContentsId, sessionId)
}
async deactivateSession(webContentsId: number): Promise {
- this.sessionManager.unbindWindow(webContentsId)
- publishDeepchatEvent('sessions.updated', {
- sessionIds: [],
- reason: 'deactivated',
- activeSessionId: null,
- webContentsId
- })
+ await this.sessionProjection.deactivate(webContentsId)
}
async getActiveSession(webContentsId: number): Promise {
- const sessionId = this.sessionManager.getActiveSessionId(webContentsId)
- if (!sessionId) return null
- const session = await this.getSession(sessionId)
- if (!session) {
- this.sessionManager.unbindWindow(webContentsId)
- }
- return session
+ return await this.sessionProjection.getActive(webContentsId)
}
getActiveSessionId(webContentsId: number): string | null {
- return this.sessionManager.getActiveSessionId(webContentsId)
+ return this.sessionProjection.getActiveId(webContentsId)
}
async renameSession(sessionId: string, title: string): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
-
- const normalized = title.trim()
- if (!normalized) {
- throw new Error('Session title cannot be empty.')
- }
-
- this.sessionManager.update(sessionId, { title: normalized })
- this.emitSessionListUpdated({
- sessionIds: [sessionId],
- reason: 'updated'
- })
+ await this.sessionProjection.renameSession(sessionId, title)
}
async toggleSessionPinned(sessionId: string, pinned: boolean): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
-
- this.sessionManager.update(sessionId, { isPinned: pinned })
- this.emitSessionListUpdated({
- sessionIds: [sessionId],
- reason: 'updated'
- })
+ await this.sessionProjection.toggleSessionPinned(sessionId, pinned)
}
async clearSessionMessages(sessionId: string): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
-
- await this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle.cancel()
- await this.sharedData.transcriptMutation.clearMessages(sessionId)
- this.emitSessionListUpdated({
- sessionIds: [sessionId],
- reason: 'updated'
- })
+ await this.sessionTurn.clearSessionMessages(sessionId)
}
async deleteSession(sessionId: string): Promise {
- const deletedSessionIds = await this.deleteSessionInternal(sessionId)
- this.emitSessionListUpdated({
- sessionIds: deletedSessionIds,
- reason: 'deleted'
- })
+ await this.sessionLifecycle.deleteSession(sessionId)
}
async getAgentTransferImpact(agentId: string): Promise {
- const normalizedAgentId = agentId.trim()
- if (!normalizedAgentId) {
- throw new Error('Agent id is required.')
- }
-
- const sessions = this.sessionManager.list({
- agentId: normalizedAgentId,
- includeSubagents: true
- })
- const samples: AgentTransferImpactSample[] = []
- let emptyDrafts = 0
- let movableSessions = 0
- let blockedSessions = 0
-
- for (const session of sessions) {
- const assessment = await this.assessTransferSession(session)
- if (assessment.isEmptyDraft) {
- emptyDrafts += 1
- }
- if (assessment.blockReason) {
- blockedSessions += 1
- } else if (!assessment.isEmptyDraft) {
- movableSessions += 1
- }
-
- if (samples.length < 6 && (!assessment.isEmptyDraft || assessment.blockReason)) {
- samples.push({
- id: session.id,
- title: session.title,
- sessionKind: session.sessionKind,
- isDraft: Boolean(session.isDraft),
- projectDir: session.projectDir,
- status: assessment.status,
- blockReason: assessment.blockReason
- })
- }
- }
-
- return {
- agentId: normalizedAgentId,
- totalSessions: sessions.length,
- regularSessions: sessions.filter((session) => session.sessionKind === 'regular').length,
- subagentSessions: sessions.filter((session) => session.sessionKind === 'subagent').length,
- emptyDrafts,
- movableSessions,
- blockedSessions,
- samples
- }
+ return await this.sessionAssignment.getAgentTransferImpact(agentId)
}
async moveAgentSessions(
fromAgentId: string,
toAgentId: string
): Promise<{ movedSessionIds: string[]; deletedSessionIds: string[] }> {
- const sourceAgentId = fromAgentId.trim()
- const targetAgentId = toAgentId.trim()
- if (!sourceAgentId || !targetAgentId) {
- throw new Error('Source and target agent ids are required.')
- }
- if (sourceAgentId === targetAgentId) {
- throw new Error('Source and target agents cannot be the same.')
- }
- await this.resolveTransferTargetContext(targetAgentId, null)
-
- const sessions = this.sessionManager.list({
- agentId: sourceAgentId,
- includeSubagents: true
- })
- const transferSessionIds: string[] = []
- const emptyDraftSessionIds: string[] = []
- const movedSessionIds: string[] = []
- const deletedSessionIds: string[] = []
- const deletedSessionIdSet = new Set()
-
- for (const session of sessions) {
- const assessment = await this.assessTransferSession(session)
- if (assessment.blockReason) {
- throw new Error(`Session ${session.id} cannot be moved: ${assessment.blockReason}`)
- }
- if (assessment.isEmptyDraft) {
- emptyDraftSessionIds.push(session.id)
- continue
- }
-
- await this.resolveTransferTargetContext(targetAgentId, session.projectDir)
- transferSessionIds.push(session.id)
- }
-
- try {
- for (const sessionId of transferSessionIds) {
- if (deletedSessionIdSet.has(sessionId)) {
- continue
- }
- if (!this.sessionManager.get(sessionId)) {
- throw new Error(`Session ${sessionId} is no longer available.`)
- }
- await this.moveSessionToAgentInternal(sessionId, targetAgentId, true)
- movedSessionIds.push(sessionId)
- }
-
- for (const sessionId of emptyDraftSessionIds) {
- if (deletedSessionIdSet.has(sessionId)) {
- continue
- }
- if (!this.sessionManager.get(sessionId)) {
- throw new Error(`Session ${sessionId} is no longer available.`)
- }
- const deleted = await this.deleteSessionInternal(sessionId)
- deleted.forEach((deletedSessionId) => deletedSessionIdSet.add(deletedSessionId))
- deletedSessionIds.push(...deleted)
- }
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error)
- const partialCounts = [
- movedSessionIds.length > 0 ? `${movedSessionIds.length} moved` : '',
- deletedSessionIds.length > 0 ? `${deletedSessionIds.length} deleted` : ''
- ].filter(Boolean)
-
- if (partialCounts.length > 0) {
- if (movedSessionIds.length > 0) {
- this.emitSessionListUpdated({
- sessionIds: movedSessionIds,
- reason: 'updated'
- })
- }
- if (deletedSessionIds.length > 0) {
- this.emitSessionListUpdated({
- sessionIds: deletedSessionIds,
- reason: 'deleted'
- })
- }
- throw new Error(`${message} Partial transfer completed: ${partialCounts.join(', ')}.`)
- }
- throw error
- }
-
- if (movedSessionIds.length > 0) {
- this.emitSessionListUpdated({
- sessionIds: movedSessionIds,
- reason: 'updated'
- })
- }
- if (deletedSessionIds.length > 0) {
- this.emitSessionListUpdated({
- sessionIds: deletedSessionIds,
- reason: 'deleted'
- })
- }
-
- return { movedSessionIds, deletedSessionIds }
+ return await this.sessionAssignment.moveAgentSessions(fromAgentId, toAgentId)
}
async deleteAgentSessions(agentId: string): Promise {
- const normalizedAgentId = agentId.trim()
- if (!normalizedAgentId) {
- throw new Error('Agent id is required.')
- }
-
- const sessions = this.sessionManager.list({
- agentId: normalizedAgentId,
- includeSubagents: true
- })
- const deletedSessionIds: string[] = []
- const deletedSessionIdSet = new Set()
-
- for (const session of sessions) {
- const assessment = await this.assessTransferSession(session)
- if (assessment.blockReason) {
- throw new Error(`Session ${session.id} cannot be deleted: ${assessment.blockReason}`)
- }
- }
-
- for (const session of sessions) {
- if (deletedSessionIdSet.has(session.id) || !this.sessionManager.get(session.id)) {
- continue
- }
- const deleted = await this.deleteSessionInternal(session.id)
- deleted.forEach((sessionId) => deletedSessionIdSet.add(sessionId))
- deletedSessionIds.push(...deleted)
- }
-
- if (deletedSessionIds.length > 0) {
- this.emitSessionListUpdated({
- sessionIds: deletedSessionIds,
- reason: 'deleted'
- })
- }
-
- return deletedSessionIds
+ return await this.sessionAssignment.deleteAgentSessions(agentId)
}
async moveSessionToAgent(sessionId: string, toAgentId: string): Promise {
- const updated = await this.moveSessionToAgentInternal(sessionId, toAgentId)
- this.emitSessionListUpdated({
- sessionIds: [sessionId],
- reason: 'updated'
- })
- return updated
+ return await this.sessionAssignment.moveSessionToAgent(sessionId, toAgentId)
}
async cancelGeneration(sessionId: string): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) return
- await this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle.cancel()
+ await this.sessionTurn.cancelGeneration(sessionId)
}
clearSessionPermissions(sessionId: string): void {
@@ -1519,13 +348,7 @@ export class AgentSessionPresenter {
toolCallId: string,
response: ToolInteractionResponse
): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
- return await this.agentManager
- .resolveSessionHandle(toAppSessionId(sessionId))
- .handle.toolInteractions.respond(messageId, toolCallId, response)
+ return await this.sessionTurn.respondToolInteraction(sessionId, messageId, toolCallId, response)
}
async getAcpSessionCommands(sessionId: string): Promise<
@@ -1535,31 +358,11 @@ export class AgentSessionPresenter {
input?: { hint: string } | null
}>
> {
- const session = this.sessionManager.get(sessionId)
- if (!session) return []
- const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle
- if (handle.kind === 'acp') {
- return await handle.acp.getCommands()
- }
- if ((await handle.snapshot())?.providerId !== 'acp') {
- return []
- }
- return await this.requireAcpAsLlmProviderSessionControl().getAcpSessionCommands(sessionId)
+ return await this.sessionAssignment.getAcpSessionCommands(sessionId)
}
async getAcpSessionConfigOptions(sessionId: string): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- return null
- }
- const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle
- if (handle.kind === 'acp') {
- return await handle.acp.getConfigOptions()
- }
- if ((await handle.snapshot())?.providerId !== 'acp') {
- return null
- }
- return await this.requireAcpAsLlmProviderSessionControl().getAcpSessionConfigOptions(sessionId)
+ return await this.sessionAssignment.getAcpSessionConfigOptions(sessionId)
}
async setAcpSessionConfigOption(
@@ -1567,75 +370,19 @@ export class AgentSessionPresenter {
configId: string,
value: string | boolean
): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
- const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle
- if (handle.kind === 'acp') {
- return await handle.acp.setConfigOption(configId, value)
- }
- if ((await handle.snapshot())?.providerId !== 'acp') {
- throw new Error('ACP session config options are only available for ACP sessions.')
- }
- return await this.requireAcpAsLlmProviderSessionControl().setAcpSessionConfigOption(
- sessionId,
- configId,
- value
- )
+ return await this.sessionAssignment.setAcpSessionConfigOption(sessionId, configId, value)
}
async getPermissionMode(sessionId: string): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
- return await this.agentManager
- .resolveSessionHandle(toAppSessionId(sessionId))
- .handle.settings.getPermissionMode()
+ return await this.sessionAssignment.getPermissionMode(sessionId)
}
async setPermissionMode(sessionId: string, mode: PermissionMode): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
- await this.agentManager
- .resolveSessionHandle(toAppSessionId(sessionId))
- .handle.settings.setPermissionMode(mode)
+ await this.sessionAssignment.setPermissionMode(sessionId, mode)
}
async setSessionSubagentEnabled(sessionId: string, enabled: boolean): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
-
- if (session.sessionKind !== 'regular') {
- throw new Error('Only regular sessions can change subagent state.')
- }
-
- const { descriptor } = this.agentManager.resolveSessionBackend(toAppSessionId(sessionId))
- if (descriptor.kind !== 'deepchat') {
- throw new Error('Only DeepChat sessions can change subagent state.')
- }
-
- this.sessionManager.update(sessionId, { subagentEnabled: enabled })
- const updated = this.sessionManager.get(sessionId)
- if (!updated) {
- throw new Error(`Session not found after update: ${sessionId}`)
- }
-
- this.emitSessionListUpdated({
- sessionIds: [sessionId],
- reason: 'updated'
- })
- const sessionWithState = await this.tryBuildSessionWithState(updated)
- if (!sessionWithState) {
- throw new Error(`Failed to build session state for sessionId: ${sessionId}`)
- }
-
- return sessionWithState
+ return await this.sessionAssignment.setSessionSubagentEnabled(sessionId, enabled)
}
async setSessionModel(
@@ -1643,994 +390,38 @@ export class AgentSessionPresenter {
providerId: string,
modelId: string
): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
-
- const nextProviderId = providerId?.trim()
- const nextModelId = modelId?.trim()
- if (!nextProviderId || !nextModelId) {
- throw new Error('setSessionModel requires providerId and modelId.')
- }
-
- const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle
- if (handle.kind !== 'deepchat') {
- throw new Error('ACP session model is locked.')
- }
- await handle.deepchat.setModel(nextProviderId, nextModelId)
- const state = await handle.snapshot()
- const updated: SessionWithState = {
- ...session,
- status: state?.status ?? 'idle',
- providerId: state?.providerId ?? nextProviderId,
- modelId: state?.modelId ?? nextModelId
- }
- this.emitSessionListUpdated({
- sessionIds: [sessionId],
- reason: 'updated'
- })
- return updated
+ return await this.sessionAssignment.setSessionModel(sessionId, providerId, modelId)
}
async setSessionProjectDir(
sessionId: string,
projectDir: string | null
): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
-
- const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle
- const state = await handle.snapshot()
- const providerId = state?.providerId?.trim() || (handle.kind === 'acp' ? 'acp' : '')
- const normalizedProjectDir = projectDir?.trim() || null
- this.assertAcpSessionHasWorkdir(providerId, normalizedProjectDir)
-
- this.sessionManager.update(sessionId, { projectDir: normalizedProjectDir })
-
- // Sync environment for new project dir
- if (normalizedProjectDir) {
- this.sqlitePresenter.newEnvironmentsTable.syncPath(normalizedProjectDir)
- }
-
- await handle.settings.setProjectDir(normalizedProjectDir)
- await this.syncAcpSessionWorkdir(providerId, sessionId, session.agentId, normalizedProjectDir)
-
- const updated = this.sessionManager.get(sessionId)
- if (!updated) {
- throw new Error(`Session not found after update: ${sessionId}`)
- }
-
- this.emitSessionListUpdated({
- sessionIds: [sessionId],
- reason: 'updated'
- })
- const sessionWithState = await this.tryBuildSessionWithState(updated)
- if (!sessionWithState) {
- throw new Error(`Failed to build session state after project update: ${sessionId}`)
- }
- return sessionWithState
+ return await this.sessionAssignment.setSessionProjectDir(sessionId, projectDir)
}
async getSessionGenerationSettings(sessionId: string): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
- return await this.agentManager
- .resolveSessionHandle(toAppSessionId(sessionId))
- .handle.settings.getGenerationSettings()
+ return await this.sessionAssignment.getSessionGenerationSettings(sessionId)
}
async getSessionDisabledAgentTools(sessionId: string): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
-
- return this.sessionManager.getDisabledAgentTools(sessionId)
+ return await this.sessionAssignment.getSessionDisabledAgentTools(sessionId)
}
async updateSessionDisabledAgentTools(
sessionId: string,
disabledAgentTools: string[]
): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
-
- const normalized = this.normalizeDisabledAgentTools(disabledAgentTools)
- this.sessionManager.updateDisabledAgentTools(sessionId, normalized)
-
- const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle
- if (handle.kind === 'deepchat') handle.deepchat.invalidateSystemPromptCache()
-
- return normalized
+ return await this.sessionAssignment.updateSessionDisabledAgentTools(
+ sessionId,
+ disabledAgentTools
+ )
}
async updateSessionGenerationSettings(
sessionId: string,
settings: Partial
): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
- return await this.agentManager
- .resolveSessionHandle(toAppSessionId(sessionId))
- .handle.settings.updateGenerationSettings(settings)
- }
-
- private async generateSessionTitle(
- sessionId: string,
- initialTitle: string,
- fallbackProviderId: string,
- fallbackModelId: string
- ): Promise {
- try {
- const titleMessages = await this.waitForSessionTitleMessages(sessionId)
- if (!titleMessages) return
-
- const currentSession = this.sessionManager.get(sessionId)
- if (!currentSession) return
- if (currentSession.title !== initialTitle) return
-
- const assistantSelection = await resolveAssistantModelSelection(
- {
- agentManager: this.agentManager,
- configPresenter: this.configPresenter
- },
- currentSession.agentId,
- fallbackProviderId,
- fallbackModelId
- )
- const preferredProviderId = assistantSelection.providerId
- const preferredModelId = assistantSelection.modelId
-
- let generatedTitle: string
- try {
- generatedTitle = await this.llmProviderPresenter.summaryTitles(
- titleMessages,
- preferredProviderId,
- preferredModelId
- )
- } catch (error) {
- const shouldFallback =
- preferredProviderId !== fallbackProviderId || preferredModelId !== fallbackModelId
- if (!shouldFallback) throw error
- generatedTitle = await this.llmProviderPresenter.summaryTitles(
- titleMessages,
- fallbackProviderId,
- fallbackModelId
- )
- }
-
- const normalized = this.normalizeGeneratedTitle(generatedTitle)
- if (!normalized || normalized === initialTitle) return
-
- const latest = this.sessionManager.get(sessionId)
- if (!latest) return
- if (latest.title !== initialTitle) return
-
- this.sessionManager.update(sessionId, { title: normalized })
- this.emitSessionListUpdated({
- sessionIds: [sessionId],
- reason: 'updated'
- })
- } catch (error) {
- console.warn(
- `[AgentSessionPresenter] title generation skipped for session=${sessionId}:`,
- error
- )
- }
- }
-
- private emitSessionListUpdated(
- options: {
- sessionIds?: string[]
- reason?: 'created' | 'updated' | 'deleted' | 'list-refreshed'
- activeSessionId?: string | null
- webContentsId?: number
- } = {}
- ): void {
- const sessionIds = Array.from(
- new Set(options.sessionIds?.map((sessionId) => sessionId.trim()).filter(Boolean) ?? [])
- )
- const reason = options.reason ?? (sessionIds.length > 0 ? 'updated' : 'list-refreshed')
-
- publishDeepchatEvent('sessions.updated', {
- sessionIds,
- reason,
- activeSessionId: options.activeSessionId,
- webContentsId: options.webContentsId
- })
- this.sessionUiPort?.refreshSessionUi()
- }
-
- private async waitForSessionTitleMessages(
- sessionId: string
- ): Promise | null> {
- const MAX_WAIT_MS = 30000
- const POLL_MS = 250
- const startedAt = Date.now()
- const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
- const readTitleMessages = async () => {
- const titleMessages = this.buildTitleMessages(
- await this.sharedData.transcript.getMessages(sessionId)
- )
- return titleMessages.length > 0 ? titleMessages : null
- }
-
- while (Date.now() - startedAt < MAX_WAIT_MS) {
- const session = this.sessionManager.get(sessionId)
- if (!session) return null
-
- const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle
- const state = await handle.snapshot()
- if (!state) return null
- if (state.status === 'error') return null
- if (state.status === 'idle') {
- const titleMessages = await readTitleMessages()
- if (titleMessages) {
- return titleMessages
- }
- }
-
- const remainingMs = MAX_WAIT_MS - (Date.now() - startedAt)
- const ready = await handle.waitForFirstTurnReady({
- timeoutMs: Math.min(POLL_MS, Math.max(0, remainingMs))
- })
- if (!ready) {
- continue
- }
-
- const titleMessages = await readTitleMessages()
- if (titleMessages) {
- return titleMessages
- }
-
- await sleep(POLL_MS)
- }
-
- return null
- }
-
- private async buildSessionWithState(
- record: SessionRecord,
- mode: 'full' | 'list' = 'full'
- ): Promise {
- const state = await this.agentManager
- .resolveSessionHandle(toAppSessionId(record.id))
- .handle.snapshot({ lightweight: mode === 'list' })
- const status = state?.status ?? 'idle'
- this.sessionStatusSnapshots.set(record.id, status)
- return {
- ...record,
- status,
- providerId: state?.providerId ?? '',
- modelId: state?.modelId ?? ''
- }
- }
-
- private mapSessionRecordToListItem(record: SessionRecord): SessionListItem {
- return {
- ...record,
- status: this.sessionStatusSnapshots.get(record.id) ?? 'idle'
- }
- }
-
- private dedupeAndSortSessionListItems(items: SessionListItem[]): SessionListItem[] {
- const sessionMap = new Map()
- for (const item of items) {
- sessionMap.set(item.id, item)
- }
-
- return Array.from(sessionMap.values()).sort((left, right) => {
- if (right.updatedAt !== left.updatedAt) {
- return right.updatedAt - left.updatedAt
- }
-
- return right.id.localeCompare(left.id)
- })
- }
-
- private matchesLightweightFilter(
- record: SessionRecord,
- options?: {
- includeSubagents?: boolean
- agentId?: string
- }
- ): boolean {
- if (options?.agentId && record.agentId !== options.agentId) {
- return false
- }
-
- if (options?.includeSubagents !== true && record.sessionKind === 'subagent') {
- return false
- }
-
- return true
- }
-
- private async tryBuildSessionWithState(
- record: SessionRecord,
- mode: 'full' | 'list' = 'full'
- ): Promise {
- try {
- return await this.buildSessionWithState(record, mode)
- } catch (error) {
- console.warn(
- `[AgentSessionPresenter] Skipping unavailable session id=${record.id} agent=${record.agentId}:`,
- error
- )
- return null as unknown as SessionWithState
- }
- }
-
- private requireDirectAcpHandle(sessionId: string): DirectAcpSessionHandle {
- const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle
- if (handle.kind !== 'acp') {
- throw new Error(`Session ${sessionId} is not a direct ACP session.`)
- }
- return handle
- }
-
- private requireAcpAsLlmProviderSessionControl(): AcpAsLlmProviderSessionControlPort {
- if (this.acpAsLlmProviderSessionControl) {
- return this.acpAsLlmProviderSessionControl
- }
- throw new Error('ACP-as-LLM provider session control is not available.')
- }
-
- private async resolveDeepChatAgentConfigCompat(
- agentId: string
- ): Promise> | null> {
- if (typeof this.configPresenter.resolveDeepChatAgentConfig !== 'function') {
- return {} as Awaited>
- }
-
- return await this.configPresenter.resolveDeepChatAgentConfig(agentId)
- }
-
- private getDefaultProjectPathCompat(): string | null {
- if (typeof this.configPresenter.getDefaultProjectPath !== 'function') {
- return null
- }
-
- return this.configPresenter.getDefaultProjectPath() ?? null
- }
-
- private resolveCreateSessionProjectDir(
- inputProjectDir: string | null | undefined,
- agentDefaultProjectDir: string | null | undefined
- ): string | null {
- if (inputProjectDir === null) {
- return null
- }
-
- return (
- inputProjectDir?.trim() ||
- agentDefaultProjectDir?.trim() ||
- this.getDefaultProjectPathCompat() ||
- null
- )
- }
-
- private mergeDeepChatDefaultGenerationSettings(
- config: Awaited> | null,
- overrides?: Partial
- ): Partial | undefined {
- const defaults: Partial = {}
-
- if (typeof config?.systemPrompt === 'string') {
- defaults.systemPrompt = config.systemPrompt
- }
-
- const merged = {
- ...defaults,
- ...overrides
- }
-
- return Object.keys(merged).length > 0 ? merged : undefined
- }
-
- private resolveSessionSubagentEnabled(
- agentType: 'deepchat' | 'acp' | null,
- inputEnabled?: boolean,
- configEnabled?: boolean
- ): boolean {
- if (agentType !== 'deepchat') {
- return false
- }
-
- if (typeof inputEnabled === 'boolean') {
- return inputEnabled
- }
-
- return configEnabled === true
- }
-
- private async resolveSubagentSessionRuntimeConfig(input: {
- agentId: string
- targetAgentId?: string | null
- providerId: string
- modelId: string
- generationSettings?: Partial
- disabledAgentTools?: string[]
- activeSkills?: string[]
- }): Promise<{
- agentId: string
- targetAgentId: string | null
- providerId: string
- modelId: string
- generationSettings?: Partial
- disabledAgentTools: string[]
- activeSkills: string[]
- }> {
- const trimmedAgentId = input.agentId.trim()
- const resolvedAgentId = resolveAcpAgentAlias(trimmedAgentId)
- let descriptor
- try {
- descriptor = this.agentManager.resolveBackend(resolvedAgentId).descriptor
- } catch {
- throw new Error(`Agent ${input.agentId} is not a valid subagent target.`)
- }
-
- if (descriptor.kind === 'acp') {
- return {
- agentId: descriptor.id,
- targetAgentId: input.targetAgentId?.trim() ? descriptor.id : null,
- providerId: 'acp',
- modelId: descriptor.id,
- generationSettings: {
- systemPrompt: ''
- },
- disabledAgentTools: [],
- activeSkills: []
- }
- }
-
- return {
- agentId: descriptor.id,
- targetAgentId: input.targetAgentId?.trim() ? descriptor.id : null,
- providerId: input.providerId,
- modelId: input.modelId,
- generationSettings: input.generationSettings,
- disabledAgentTools: this.normalizeDisabledAgentTools(input.disabledAgentTools),
- activeSkills: this.normalizeActiveSkills(input.activeSkills)
- }
- }
-
- private async assessTransferSession(session: SessionRecord): Promise<{
- status: SessionWithState['status']
- isEmptyDraft: boolean
- blockReason?: AgentTransferBlockReason
- }> {
- const { handle, facet } = this.agentManager.resolveTransferSource(toAppSessionId(session.id))
- const state = await handle.snapshot()
- const status = state?.status ?? 'idle'
- let hasMessages = true
- try {
- hasMessages = await facet.hasMessages(toAppSessionId(session.id))
- } catch (error) {
- console.warn(
- `[AgentSessionPresenter] Failed to inspect messages for session=${session.id}:`,
- error
- )
- }
- let hasPendingInput = false
- try {
- hasPendingInput = (await facet.listPendingInputs(toAppSessionId(session.id))).length > 0
- } catch (error) {
- console.warn(
- `[AgentSessionPresenter] Failed to inspect pending input for session=${session.id}:`,
- error
- )
- hasPendingInput = true
- }
- const hasSubagentChildren =
- session.sessionKind === 'regular' &&
- this.sessionManager.list({ includeSubagents: true, parentSessionId: session.id }).length > 0
- const isEmptyDraft = Boolean(session.isDraft) && !hasMessages && !hasSubagentChildren
-
- if (status === 'generating') {
- return { status, isEmptyDraft, blockReason: 'active' }
- }
- if (hasPendingInput) {
- return { status, isEmptyDraft, blockReason: 'pending-input' }
- }
-
- return { status, isEmptyDraft }
- }
-
- private async moveSessionToAgentInternal(
- sessionId: string,
- toAgentId: string,
- allowSubagent: boolean = false
- ): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) {
- throw new Error(`Session not found: ${sessionId}`)
- }
- if (!allowSubagent && session.sessionKind !== 'regular') {
- throw new Error('Only regular conversations can be moved from the conversation menu.')
- }
-
- const targetAgentId = toAgentId.trim()
- if (!targetAgentId) {
- throw new Error('Target agent id is required.')
- }
- if (session.agentId === targetAgentId) {
- throw new Error('Conversation is already assigned to the selected agent.')
- }
-
- const assessment = await this.assessTransferSession(session)
- if (assessment.blockReason) {
- throw new Error(`Session ${sessionId} cannot be moved: ${assessment.blockReason}`)
- }
-
- const targetContext = await this.resolveTransferTargetContext(targetAgentId, session.projectDir)
- const source = this.agentManager.resolveTransferSource(toAppSessionId(sessionId))
- const sourceState = await source.handle.snapshot()
- const previousDirectAcp = source.handle.kind === 'acp'
- const previousCompatibilityAcp =
- source.handle.kind === 'deepchat' && sourceState?.providerId === 'acp'
- const { facet: transferTarget } = this.agentManager.resolveDeepChatTransferTarget(
- targetContext.agentId
- )
-
- await transferTarget.setSessionAgentContext(toAppSessionId(sessionId), {
- agentId: targetContext.agentId,
- providerId: targetContext.providerId,
- modelId: targetContext.modelId,
- projectDir: targetContext.projectDir,
- permissionMode: targetContext.permissionMode,
- generationSettings: targetContext.generationSettings
- })
-
- this.sessionManager.updateAgentId(sessionId, targetContext.agentId)
- this.sessionManager.update(sessionId, {
- projectDir: targetContext.projectDir,
- subagentEnabled: session.sessionKind === 'regular' ? targetContext.subagentEnabled : false
- })
- this.sessionManager.updateDisabledAgentTools(sessionId, targetContext.disabledAgentTools)
-
- await this.syncAcpSessionWorkdir(
- targetContext.providerId,
- sessionId,
- targetContext.agentId,
- targetContext.projectDir
- )
-
- const updated = this.sessionManager.get(sessionId)
- if (!updated) {
- throw new Error(`Session not found after transfer: ${sessionId}`)
- }
-
- const sessionWithState = await this.tryBuildSessionWithState(updated)
- if (!sessionWithState) {
- throw new Error(`Failed to build session state after transfer: ${sessionId}`)
- }
-
- if (previousDirectAcp && source.closeRuntime) {
- try {
- await source.closeRuntime()
- } catch (error) {
- console.warn(
- `[AgentSessionPresenter] Failed to close direct ACP runtime after transfer ${sessionId}:`,
- error
- )
- }
- } else if (previousCompatibilityAcp) {
- try {
- await this.requireAcpAsLlmProviderSessionControl().clearAcpSession(sessionId)
- } catch (error) {
- console.warn(
- `[AgentSessionPresenter] Failed to clear stale ACP binding after transfer ${sessionId}:`,
- error
- )
- }
- }
-
- return sessionWithState
- }
-
- private async resolveTransferTargetContext(
- targetAgentId: string,
- currentProjectDir: string | null
- ): Promise {
- const resolvedAgentId = resolveAcpAgentAlias(targetAgentId.trim())
- let descriptor
- try {
- descriptor = this.agentManager.resolveBackend(resolvedAgentId).descriptor
- } catch {
- throw new Error(`Target agent not found: ${targetAgentId}`)
- }
- if (descriptor.kind === 'acp') {
- throw new Error('Conversation history cannot be moved to ACP agents.')
- }
-
- const currentProject = currentProjectDir?.trim() || null
- const config = await this.resolveDeepChatAgentConfigCompat(descriptor.id)
- const defaultModel = this.configPresenter.getDefaultModel()
- const providerId =
- config?.defaultModelPreset?.providerId?.trim() || defaultModel?.providerId?.trim() || ''
- const modelId =
- config?.defaultModelPreset?.modelId?.trim() || defaultModel?.modelId?.trim() || ''
- if (!providerId || !modelId) {
- throw new Error('Target DeepChat agent does not have a default model.')
- }
- if (providerId.toLowerCase() === 'acp') {
- throw new Error('Conversation history cannot be moved to ACP agents.')
- }
-
- return {
- agentId: descriptor.id,
- agentType: 'deepchat',
- providerId,
- modelId,
- projectDir:
- currentProject ||
- config?.defaultProjectPath?.trim() ||
- this.getDefaultProjectPathCompat() ||
- null,
- permissionMode: normalizePermissionMode(config?.permissionMode),
- generationSettings: this.mergeDeepChatDefaultGenerationSettings(config),
- disabledAgentTools: this.normalizeDisabledAgentTools(config?.disabledAgentTools),
- subagentEnabled: this.resolveSessionSubagentEnabled(
- 'deepchat',
- undefined,
- config?.subagentEnabled
- )
- }
- }
-
- private async deleteSessionInternal(sessionId: string): Promise {
- const session = this.sessionManager.get(sessionId)
- if (!session) return []
-
- const deletedSessionIds: string[] = []
-
- if (session.sessionKind === 'regular') {
- const children = this.sessionManager.list({
- includeSubagents: true,
- parentSessionId: sessionId
- })
- for (const child of children) {
- deletedSessionIds.push(...(await this.deleteSessionInternal(child.id)))
- }
- }
-
- let backendCleanupError: unknown
- try {
- await this.agentManager.cleanupSessionBackends(toAppSessionId(sessionId))
- } catch (error) {
- backendCleanupError = error
- }
- try {
- await this.sharedData.sessionState.destroySession(sessionId)
- } catch (error) {
- if (!backendCleanupError) throw error
- }
- if (backendCleanupError) throw backendCleanupError
- this.sessionPermissionPort?.clearSessionPermissions(sessionId)
- await this.skillPresenter?.clearNewAgentSessionSkills?.(sessionId)
- this.sessionManager.delete(sessionId)
- this.sessionStatusSnapshots.delete(sessionId)
- deletedSessionIds.push(sessionId)
-
- return deletedSessionIds
- }
-
- private async findReusableDraftSession(
- agentId: string,
- projectDir: string
- ): Promise {
- const candidates = this.sessionManager.list({ agentId, projectDir })
- for (const session of candidates) {
- if (!session.isDraft) continue
- const hasMessages = await this.hasSessionMessages(session.id)
- if (!hasMessages) {
- return session
- }
- }
- return null
- }
-
- private async hasSessionMessages(sessionId: string): Promise {
- try {
- return await this.sharedData.transcript.hasMessages(sessionId)
- } catch (error) {
- console.warn(
- `[AgentSessionPresenter] Failed to inspect messages for session=${sessionId}:`,
- error
- )
- return true
- }
- }
-
- private async ensureSessionRuntimeInitialized(
- sessionId: string,
- config: {
- agentId?: string
- providerId: string
- modelId: string
- projectDir: string
- permissionMode: PermissionMode
- }
- ): Promise {
- const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle
- if (!(await handle.lifecycle.isInitialized())) {
- await this.initializeSessionRuntime(sessionId, config)
- return
- }
- const state = await handle.snapshot()
- if (!state) throw new Error(`Session ${sessionId} not found`)
-
- if (state.permissionMode && state.permissionMode !== config.permissionMode) {
- await handle.settings.setPermissionMode(config.permissionMode)
- }
-
- await this.syncAcpSessionWorkdir(
- config.providerId,
- sessionId,
- config.agentId ?? config.modelId,
- config.projectDir
- )
- }
-
- private async initializeSessionRuntime(
- sessionId: string,
- config: {
- agentId?: string
- providerId: string
- modelId: string
- projectDir?: string | null
- permissionMode: PermissionMode
- generationSettings?: Partial
- }
- ): Promise {
- await this.agentManager
- .resolveSessionHandle(toAppSessionId(sessionId))
- .handle.lifecycle.initialize(config)
- await this.syncAcpSessionWorkdir(
- config.providerId,
- sessionId,
- config.agentId ?? config.modelId,
- config.projectDir ?? null
- )
- }
-
- private async syncAcpSessionWorkdir(
- providerId: string,
- conversationId: string,
- agentId: string,
- projectDir?: string | null
- ): Promise {
- if (providerId !== 'acp') {
- return
- }
-
- const normalizedProjectDir = projectDir?.trim()
- if (!normalizedProjectDir) {
- return
- }
-
- try {
- const handle = this.agentManager.resolveSessionHandle(toAppSessionId(conversationId)).handle
- if (handle.kind === 'acp') {
- await handle.acp.updateWorkdir(normalizedProjectDir)
- return
- }
- await this.requireAcpAsLlmProviderSessionControl().setAcpWorkdir(
- conversationId,
- resolveAcpAgentAlias(agentId),
- normalizedProjectDir
- )
- } catch (error) {
- console.warn('[AgentSessionPresenter] Failed to sync ACP workdir for session:', {
- conversationId,
- agentId,
- projectDir: normalizedProjectDir,
- error
- })
- throw error
- }
- }
-
- private async cleanupFailedSessionInitialization(
- sessionId: string,
- providerId?: string
- ): Promise {
- const handle = this.agentManager.resolveSessionHandle(toAppSessionId(sessionId)).handle
- if (providerId === 'acp' && handle.kind !== 'acp') {
- try {
- await this.requireAcpAsLlmProviderSessionControl().clearAcpSession(sessionId)
- } catch (error) {
- console.warn(
- `[AgentSessionPresenter] Failed to clear ACP session after initialization error ${sessionId}:`,
- error
- )
- }
- }
-
- try {
- await handle.close()
- } catch (cleanupError) {
- console.warn(
- `[AgentSessionPresenter] Failed to cleanup session runtime after initialization error ${sessionId}:`,
- cleanupError
- )
- }
-
- this.sessionManager.delete(sessionId)
- }
-
- private buildTitleMessages(
- records: ChatMessageRecord[]
- ): Array<{ role: 'system' | 'user' | 'assistant'; content: string }> {
- const sorted = [...records].sort((a, b) => a.orderSeq - b.orderSeq)
- const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = []
-
- for (const record of sorted) {
- if (record.role === 'user') {
- const text = this.extractUserText(record.content)
- if (text) {
- messages.push({ role: 'user', content: text })
- }
- continue
- }
-
- if (record.role === 'assistant') {
- const text = this.extractAssistantText(record.content)
- if (text) {
- messages.push({ role: 'assistant', content: text })
- }
- }
- }
-
- return messages.slice(0, 6)
- }
-
- private extractUserText(content: string): string {
- try {
- const parsed = JSON.parse(content) as UserMessageContent | string
- if (typeof parsed === 'string') return parsed.trim()
- return typeof parsed.text === 'string' ? parsed.text.trim() : ''
- } catch {
- return content.trim()
- }
- }
-
- private extractAssistantText(content: string): string {
- try {
- const parsed = JSON.parse(content) as AssistantMessageBlock[] | string
- if (typeof parsed === 'string') return parsed.trim()
- if (!Array.isArray(parsed)) return ''
- return parsed
- .filter((block) => block.type === 'content')
- .map((block) => block.content)
- .join('\n')
- .trim()
- } catch {
- return content.trim()
- }
- }
-
- private normalizeGeneratedTitle(rawTitle: string): string {
- if (!rawTitle) return ''
- let cleaned = rawTitle.replace(/.*?<\/think>/gs, '').trim()
- cleaned = cleaned.replace(/^/, '').trim()
- cleaned = cleaned.replace(/^["'`]+|["'`]+$/g, '').trim()
- if (cleaned.length > 80) {
- cleaned = cleaned.slice(0, 80).trim()
- }
- return cleaned
- }
-
- private buildForkTitle(sourceTitle: string, customTitle?: string): string {
- const normalizedCustom = customTitle?.trim()
- if (normalizedCustom) {
- return normalizedCustom
- }
- const base = sourceTitle?.trim() || 'New Chat'
- if (base.length >= 60) {
- return base.slice(0, 60).trim()
- }
- return `${base} - Fork`
- }
-
- private assertAcpSessionHasWorkdir(providerId: string, projectDir: string | null): void {
- if (providerId !== 'acp') {
- return
- }
- if (projectDir?.trim()) {
- return
- }
- throw new Error('ACP agent requires selecting a workdir before sending messages.')
- }
-
- private normalizeSendMessageInput(content: string | SendMessageInput): SendMessageInput {
- if (typeof content === 'string') {
- return { text: content, files: [] }
- }
-
- if (!content || typeof content !== 'object') {
- return { text: '', files: [] }
- }
-
- const text = typeof content.text === 'string' ? content.text : ''
- const files = Array.isArray(content.files)
- ? content.files.filter((file): file is MessageFile => Boolean(file))
- : []
- const activeSkills = this.normalizeActiveSkills(content.activeSkills)
- const inlineItems = Array.isArray(content.inlineItems) ? content.inlineItems : []
- return {
- text,
- files,
- ...(activeSkills.length > 0 ? { activeSkills } : {}),
- ...(inlineItems.length > 0 ? { inlineItems } : {})
- }
- }
-
- private normalizeCreateSessionInput(input: CreateSessionInput): SendMessageInput {
- const text = typeof input.message === 'string' ? input.message : ''
- const files = Array.isArray(input.files)
- ? input.files.filter((file): file is MessageFile => Boolean(file))
- : []
- const inlineItems = Array.isArray(input.inlineItems) ? input.inlineItems : []
- return this.withInitialMessageActiveSkills(
- {
- text,
- files,
- ...(inlineItems.length > 0 ? { inlineItems } : {})
- },
- input.activeSkills
- )
- }
-
- private withInitialMessageActiveSkills(
- input: SendMessageInput,
- activeSkills?: string[]
- ): SendMessageInput {
- const normalizedActiveSkills = this.normalizeActiveSkills(activeSkills ?? input.activeSkills)
- return {
- ...input,
- ...(normalizedActiveSkills.length > 0 ? { activeSkills: normalizedActiveSkills } : {})
- }
- }
-
- private normalizeDisabledAgentTools(disabledAgentTools?: string[]): string[] {
- if (!Array.isArray(disabledAgentTools)) {
- return []
- }
-
- return Array.from(
- new Set(
- disabledAgentTools
- .filter((item): item is string => typeof item === 'string')
- .map((item) => item.trim())
- .map((item) => LEGACY_AGENT_TOOL_NAME_MAP[item] ?? item)
- .filter((item) => Boolean(item) && !RETIRED_DEFAULT_AGENT_TOOLS.has(item))
- )
- ).sort((left, right) => left.localeCompare(right))
- }
-
- private normalizeActiveSkills(activeSkills?: string[]): string[] {
- if (!Array.isArray(activeSkills)) {
- return []
- }
-
- return Array.from(
- new Set(
- activeSkills
- .filter((item): item is string => typeof item === 'string')
- .map((item) => item.trim())
- .filter(Boolean)
- )
- )
+ return await this.sessionAssignment.updateSessionGenerationSettings(sessionId, settings)
}
}
diff --git a/src/main/presenter/cronJobs/index.ts b/src/main/presenter/cronJobs/index.ts
index 90a2b52ca9..1e7d090e1c 100644
--- a/src/main/presenter/cronJobs/index.ts
+++ b/src/main/presenter/cronJobs/index.ts
@@ -486,4 +486,10 @@ export class CronJobsService {
}
export { CronJobsRepository }
+export { createCronJobRunSessionStarter } from './runSessionStarter'
+export type {
+ CronJobAgentCatalogPort,
+ CronJobSessionLifecyclePort,
+ CronJobSessionTurnPort
+} from './runSessionStarter'
export type { CronJobRunSessionStarter }
diff --git a/src/main/presenter/cronJobs/runSessionStarter.ts b/src/main/presenter/cronJobs/runSessionStarter.ts
new file mode 100644
index 0000000000..054f5d47a9
--- /dev/null
+++ b/src/main/presenter/cronJobs/runSessionStarter.ts
@@ -0,0 +1,113 @@
+import type {
+ AgentType,
+ CreateDetachedSessionInput,
+ MessageStartResult
+} from '@shared/types/agent-interface'
+import type { CronJobRunSessionStarter } from './runExecutor'
+
+export interface CronJobSessionLifecyclePort {
+ createDetachedSession(input: CreateDetachedSessionInput): Promise<{ id: string }>
+}
+
+export interface CronJobSessionTurnPort {
+ sendMessage(
+ sessionId: string,
+ content: string,
+ options?: { maxProviderRounds?: number }
+ ): Promise
+ cancelGeneration(sessionId: string): Promise
+}
+
+export interface CronJobAgentCatalogPort {
+ getAgentType(agentId: string): Promise
+}
+
+export const createCronJobRunSessionStarter = (deps: {
+ lifecycle: CronJobSessionLifecyclePort
+ turn: CronJobSessionTurnPort
+ agentCatalog: CronJobAgentCatalogPort
+}): CronJobRunSessionStarter => ({
+ async createSessionForRun({ job, run }) {
+ if (!job.agentId) {
+ throw new Error('Cron job requires an enabled agent.')
+ }
+ console.info('[CronJobs] Resolving agent for session:', {
+ jobId: job.id,
+ runId: run.id,
+ agentId: job.agentId
+ })
+ const agentType = await deps.agentCatalog.getAgentType(job.agentId)
+ const snapshotConfig = job.agentSnapshot?.config as
+ | {
+ defaultModelPreset?: { providerId?: string; modelId?: string } | null
+ permissionMode?: 'default' | 'auto_approve' | 'full_access'
+ disabledAgentTools?: string[]
+ subagentEnabled?: boolean
+ systemPrompt?: string
+ }
+ | null
+ | undefined
+ const modelPreset =
+ agentType === 'deepchat' && job.modelPolicy === 'pin_current'
+ ? snapshotConfig?.defaultModelPreset
+ : null
+ const systemPrompt = job.taskSystemInstruction?.trim() || snapshotConfig?.systemPrompt
+
+ const session = await deps.lifecycle.createDetachedSession({
+ agentId: job.agentId,
+ title: job.name,
+ ...(agentType === 'acp' ? { providerId: 'acp', modelId: job.agentId } : {}),
+ ...(modelPreset?.providerId ? { providerId: modelPreset.providerId } : {}),
+ ...(modelPreset?.modelId ? { modelId: modelPreset.modelId } : {}),
+ ...(job.permissionPolicy === 'snapshot' && snapshotConfig?.permissionMode
+ ? { permissionMode: snapshotConfig.permissionMode }
+ : {}),
+ ...(job.toolPolicy === 'snapshot' && snapshotConfig?.disabledAgentTools
+ ? { disabledAgentTools: snapshotConfig.disabledAgentTools }
+ : {}),
+ ...(snapshotConfig?.subagentEnabled !== undefined
+ ? { subagentEnabled: snapshotConfig.subagentEnabled }
+ : {}),
+ ...(systemPrompt ? { generationSettings: { systemPrompt } } : {}),
+ metadata: {
+ source: 'cron_job',
+ cronJobId: job.id,
+ cronJobRunId: run.id,
+ scheduledAt: run.scheduledAt
+ }
+ })
+ console.info('[CronJobs] Detached session created:', {
+ jobId: job.id,
+ runId: run.id,
+ sessionId: session.id,
+ agentType
+ })
+ return { sessionId: session.id }
+ },
+
+ async startSessionRun({ job, sessionId }) {
+ if (!job.taskPrompt.trim()) {
+ throw new Error('Cron job task prompt is empty.')
+ }
+ console.info('[CronJobs] Sending task prompt to session:', {
+ jobId: job.id,
+ sessionId,
+ promptLength: job.taskPrompt.length
+ })
+ const result = await deps.turn.sendMessage(sessionId, job.taskPrompt, {
+ maxProviderRounds: job.runtime.maxTurns
+ })
+ console.info('[CronJobs] Task prompt accepted by session:', {
+ jobId: job.id,
+ sessionId,
+ outputMessageId: result.messageId ?? null
+ })
+ return {
+ outputMessageId: result.messageId ?? null
+ }
+ },
+
+ async cancelSessionRun({ sessionId }) {
+ await deps.turn.cancelGeneration(sessionId)
+ }
+})
diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts
index f6ed2e4fb5..178175c49e 100644
--- a/src/main/presenter/index.ts
+++ b/src/main/presenter/index.ts
@@ -66,16 +66,23 @@ import type { SkillSessionStatePort } from './skillPresenter'
import { SkillSyncPresenter } from './skillSyncPresenter'
import { HooksNotificationsService } from './hooksNotifications'
import { NewSessionHooksBridge } from './hooksNotifications/newSessionBridge'
-import { CronJobsService } from './cronJobs'
+import { CronJobsService, createCronJobRunSessionStarter } from './cronJobs'
import { AgentManager } from '@/agent/manager/agentManager'
import { createDeepChatAgentBackend } from '@/agent/manager/deepChatAgentBackend'
import { createDirectAcpAgentBackend } from '@/agent/manager/directAcpAgentBackend'
import { AppSessionService } from '@/agent/shared/appSessionService'
import type { AgentSharedDataPorts } from '@/agent/shared/agentSharedData'
import { toAppSessionId } from '@/agent/shared/agentSessionIds'
+import { resolveAssistantModelSelection } from '@/agent/shared/assistantModelSelection'
import { AgentUnavailableError } from '@/agent/shared/agentCatalogCodec'
import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias'
import { AgentSessionPresenter } from './agentSessionPresenter'
+import { SessionProjectionCoordinator } from './sessionApplication/projectionCoordinator'
+import { SessionAgentAssignmentPolicy } from './sessionApplication/agentAssignmentPolicy'
+import { SessionAgentAssignmentCoordinator } from './sessionApplication/agentAssignmentCoordinator'
+import { SessionDeletionTransaction } from './sessionApplication/lifecycleDeletionTransaction'
+import { SessionTurnCoordinator } from './sessionApplication/turnCoordinator'
+import { SessionLifecycleCoordinator } from './sessionApplication/lifecycleCoordinator'
import { AgentRuntimePresenter } from './agentRuntimePresenter'
import { AcpAgentRuntime } from '@/agent/acp/instance'
import type {
@@ -167,6 +174,13 @@ export class Presenter implements IPresenter {
skillPresenter: ISkillPresenter
skillSyncPresenter: ISkillSyncPresenter
agentSessionPresenter: IAgentSessionPresenter
+ sessionProjectionCoordinator: SessionProjectionCoordinator
+ sessionAgentAssignmentPolicy: SessionAgentAssignmentPolicy
+ sessionAgentAssignmentCoordinator: SessionAgentAssignmentCoordinator
+ sessionTurnCoordinator: SessionTurnCoordinator
+ sessionLifecycleCoordinator: SessionLifecycleCoordinator
+ sessionDeletionTransaction: SessionDeletionTransaction
+ sessionPermissionPort: SessionPermissionPort
agentManager: AgentManager
acpAgentRuntime: AcpAgentRuntime
memoryPresenter: MemoryPresenter
@@ -590,6 +604,7 @@ export class Presenter implements IPresenter {
}
}
}
+ this.sessionPermissionPort = sessionPermissionPort
// Initialize agent memory layer (opt-in per agent; vectors stored separately from knowledge base)
const memoryDbDir = path.join(dbDir, 'AgentMemory')
MemoryVectorStore.recoverQuarantinedStores(memoryDbDir)
@@ -748,6 +763,175 @@ export class Presenter implements IPresenter {
}
})
})
+ this.sessionProjectionCoordinator = new SessionProjectionCoordinator({
+ sessions: appSessionService,
+ runtime: {
+ getAgentKind: (agentId) => this.agentManager.resolveBackend(agentId).kind,
+ snapshot: async (sessionId, options) =>
+ await this.agentManager
+ .resolveSessionHandle(toAppSessionId(sessionId))
+ .handle.snapshot(options),
+ waitForFirstTurnReady: async (sessionId, options) =>
+ await this.agentManager
+ .resolveSessionHandle(toAppSessionId(sessionId))
+ .handle.waitForFirstTurnReady(options)
+ },
+ transcript: agentSharedData.transcript,
+ tape: agentSharedData.tape,
+ messages: sqlitePresenter.deepchatMessagesTable,
+ searchResults: sqlitePresenter.deepchatMessageSearchResultsTable,
+ traces: sqlitePresenter.deepchatMessageTracesTable,
+ titles: this.llmproviderPresenter,
+ agentConfig: {
+ getAssistantModel: async (agentId) => {
+ const selection = await resolveAssistantModelSelection(
+ {
+ agentManager: this.agentManager,
+ configPresenter: this.configPresenter
+ },
+ agentId,
+ '',
+ ''
+ )
+ return selection.providerId && selection.modelId ? selection : null
+ }
+ },
+ events: {
+ publish: (payload) => publishDeepchatEvent('sessions.updated', payload)
+ },
+ ui: sessionUiPort
+ })
+ this.sessionAgentAssignmentPolicy = new SessionAgentAssignmentPolicy(
+ {
+ resolveAgent: (agentId) => {
+ const descriptor = this.agentManager.resolveBackend(agentId).descriptor
+ return { id: descriptor.id, kind: descriptor.kind }
+ }
+ },
+ {
+ getDefaultModel: () => this.configPresenter.getDefaultModel(),
+ getDefaultProjectPath: () => this.configPresenter.getDefaultProjectPath(),
+ resolveDeepChatAgentConfig: async (agentId) =>
+ await this.configPresenter.resolveDeepChatAgentConfig(agentId)
+ }
+ )
+ const clearNewAgentSessionSkills = this.skillPresenter.clearNewAgentSessionSkills
+ if (!clearNewAgentSessionSkills) {
+ throw new Error('Skill presenter must provide session skill cleanup.')
+ }
+ this.sessionDeletionTransaction = new SessionDeletionTransaction({
+ sessions: appSessionService,
+ runtime: {
+ cleanupSessionBackends: async (sessionId) =>
+ await this.agentManager.cleanupSessionBackends(sessionId)
+ },
+ state: agentSharedData.sessionState,
+ permissions: sessionPermissionPort,
+ skills: {
+ clearNewAgentSessionSkills: async (sessionId) =>
+ await clearNewAgentSessionSkills.call(this.skillPresenter, sessionId)
+ },
+ projection: this.sessionProjectionCoordinator
+ })
+ this.sessionAgentAssignmentCoordinator = new SessionAgentAssignmentCoordinator({
+ sessions: appSessionService,
+ runtime: {
+ getSessionAgentKind: (sessionId) =>
+ this.agentManager.resolveSessionBackend(sessionId).descriptor.kind,
+ resolveSession: (sessionId) => this.agentManager.resolveSessionHandle(sessionId),
+ resolveTransferSource: (sessionId) => this.agentManager.resolveTransferSource(sessionId),
+ resolveDeepChatTransferTarget: (agentId) =>
+ this.agentManager.resolveDeepChatTransferTarget(agentId),
+ resolveSubagentFacet: (sessionId) => this.agentManager.resolveSubagentFacet(sessionId)
+ },
+ policy: this.sessionAgentAssignmentPolicy,
+ projection: this.sessionProjectionCoordinator,
+ deletion: this.sessionDeletionTransaction,
+ environment: {
+ syncPath: (projectDir) => sqlitePresenter.newEnvironmentsTable.syncPath(projectDir)
+ },
+ acp: this.acpAsLlmProviderSessionControl
+ })
+ this.sessionTurnCoordinator = new SessionTurnCoordinator({
+ sessions: appSessionService,
+ runtime: {
+ resolveSession: (sessionId) => {
+ const { handle } = this.agentManager.resolveSessionHandle(sessionId)
+ const turn = {
+ pending: handle.pending,
+ toolInteractions: handle.toolInteractions,
+ send: (input: Parameters[0]) => handle.send(input),
+ cancel: () => handle.cancel(),
+ snapshot: () => handle.snapshot()
+ }
+ return handle.kind === 'deepchat'
+ ? {
+ ...turn,
+ kind: handle.kind,
+ compaction: {
+ getState: () => handle.deepchat.getCompactionState(),
+ compact: () => handle.deepchat.compact()
+ }
+ }
+ : { ...turn, kind: handle.kind }
+ }
+ },
+ transcript: {
+ hasMessages: (sessionId) => agentSharedData.transcript.hasMessages(sessionId),
+ clearMessages: (sessionId) => agentSharedData.transcriptMutation.clearMessages(sessionId),
+ prepareRetryMessage: (sessionId, messageId) =>
+ agentSharedData.transcriptMutation.prepareRetryMessage(sessionId, messageId),
+ deleteMessage: (sessionId, messageId) =>
+ agentSharedData.transcriptMutation.deleteMessage(sessionId, messageId),
+ editUserMessage: (sessionId, messageId, text) =>
+ agentSharedData.transcriptMutation.editUserMessage(sessionId, messageId, text)
+ },
+ workdir: this.sessionAgentAssignmentCoordinator,
+ projection: this.sessionProjectionCoordinator
+ })
+ this.sessionLifecycleCoordinator = new SessionLifecycleCoordinator({
+ sessions: appSessionService,
+ runtime: {
+ resolveSession: (sessionId) => {
+ const { handle } = this.agentManager.resolveSessionHandle(sessionId)
+ return {
+ kind: handle.kind,
+ initialize: (config) => handle.lifecycle.initialize(config),
+ isInitialized: () => handle.lifecycle.isInitialized(),
+ snapshot: () => handle.snapshot(),
+ getGenerationSettings: () => handle.settings.getGenerationSettings(),
+ setPermissionMode: (mode) => handle.settings.setPermissionMode(mode),
+ close: () => handle.close()
+ }
+ }
+ },
+ transcript: {
+ hasMessages: (sessionId) => agentSharedData.transcript.hasMessages(sessionId),
+ forkSessionFromMessage: (sourceSessionId, targetSessionId, targetMessageId) =>
+ agentSharedData.transcriptMutation.forkSessionFromMessage(
+ sourceSessionId,
+ targetSessionId,
+ targetMessageId
+ )
+ },
+ skills: {
+ setActiveSkills: async (sessionId, activeSkills) => {
+ await this.skillPresenter.setActiveSkills(sessionId, activeSkills)
+ }
+ },
+ assignmentPolicy: this.sessionAgentAssignmentPolicy,
+ workdir: this.sessionAgentAssignmentCoordinator,
+ initialTurn: this.sessionTurnCoordinator,
+ projection: this.sessionProjectionCoordinator,
+ deletion: this.sessionDeletionTransaction
+ })
+ this.cronJobs.setRunSessionStarter(
+ createCronJobRunSessionStarter({
+ lifecycle: this.sessionLifecycleCoordinator,
+ turn: this.sessionTurnCoordinator,
+ agentCatalog: this.configPresenter
+ })
+ )
this.sessionHistorySearch = new SessionHistorySearch(sqlitePresenter, appSessionService)
this.agentSessionExportService = new AgentSessionExportService({
agentManager: this.agentManager,
@@ -761,17 +945,12 @@ export class Presenter implements IPresenter {
llmProviderPresenter: this.llmproviderPresenter
})
this.agentSessionPresenter = new AgentSessionPresenter(
- this.agentManager,
- appSessionService,
- this.llmproviderPresenter as unknown as ILlmProviderPresenter,
- this.configPresenter,
- this.sqlitePresenter as unknown as import('./sqlitePresenter').SQLitePresenter,
- agentSharedData,
- this.skillPresenter,
+ this.sessionProjectionCoordinator,
+ this.sessionLifecycleCoordinator,
+ this.sessionAgentAssignmentCoordinator,
+ this.sessionTurnCoordinator,
{
- acpAsLlmProviderSessionControl: this.acpAsLlmProviderSessionControl,
- sessionPermissionPort,
- sessionUiPort
+ sessionPermissionPort
}
)
this.projectPresenter = new ProjectPresenter(
@@ -781,7 +960,10 @@ export class Presenter implements IPresenter {
)
this.#remoteControlPresenter = new RemoteControlPresenter({
configPresenter: this.configPresenter,
- agentSessionPresenter: this.agentSessionPresenter,
+ lifecycle: this.sessionLifecycleCoordinator,
+ turn: this.sessionTurnCoordinator,
+ assignment: this.sessionAgentAssignmentCoordinator,
+ projection: this.sessionProjectionCoordinator,
filePresenter: this.filePresenter,
agentManager: this.agentManager,
windowPresenter: this.windowPresenter,
@@ -1186,6 +1368,10 @@ const buildMainKernelRouteRuntime = () =>
llmProviderPresenter: presenter.llmproviderPresenter,
acpProviderAdminPort: presenter.acpProviderAdminPort,
agentSessionPresenter: presenter.agentSessionPresenter,
+ sessionLifecyclePort: presenter.sessionLifecycleCoordinator,
+ sessionProjectionPort: presenter.sessionProjectionCoordinator,
+ sessionTurnPort: presenter.sessionTurnCoordinator,
+ sessionPermissionPort: presenter.sessionPermissionPort,
skillPresenter: presenter.skillPresenter,
skillSyncPresenter: presenter.skillSyncPresenter,
exporter: presenter.exporter,
diff --git a/src/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.ts b/src/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.ts
index ae3bf09ba6..9610ec9dc5 100644
--- a/src/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.ts
+++ b/src/main/presenter/lifecyclePresenter/hooks/after-start/cronJobsStartHook.ts
@@ -1,6 +1,6 @@
import logger from '@shared/logger'
import { LifecycleHook, LifecycleContext } from '@shared/presenter'
-import { presenter, getMainKernelRouteRuntime } from '@/presenter'
+import { presenter } from '@/presenter'
import { LifecyclePhase } from '@shared/lifecycle'
export const cronJobsStartHook: LifecycleHook = {
@@ -13,15 +13,6 @@ export const cronJobsStartHook: LifecycleHook = {
throw new Error('cronJobsStartHook: Presenter not initialized')
}
- try {
- getMainKernelRouteRuntime()
- } catch (error) {
- console.warn(
- '[cronJobsStartHook] Failed to prime route runtime; cron jobs cannot create sessions until routes initialize:',
- error
- )
- }
-
presenter.cronJobs.start()
logger.info('cronJobsStartHook: Scheduler reconciled')
}
diff --git a/src/main/presenter/remoteControlPresenter/index.ts b/src/main/presenter/remoteControlPresenter/index.ts
index 7e19b68bae..819efc5dce 100644
--- a/src/main/presenter/remoteControlPresenter/index.ts
+++ b/src/main/presenter/remoteControlPresenter/index.ts
@@ -2675,7 +2675,10 @@ export class RemoteControlPresenter {
return new RemoteConversationRunner(
{
configPresenter: this.deps.configPresenter,
- agentSessionPresenter: this.deps.agentSessionPresenter,
+ lifecycle: this.deps.lifecycle,
+ turn: this.deps.turn,
+ assignment: this.deps.assignment,
+ projection: this.deps.projection,
filePresenter: this.deps.filePresenter,
agentManager: this.deps.agentManager,
windowPresenter: this.deps.windowPresenter,
diff --git a/src/main/presenter/remoteControlPresenter/interface.ts b/src/main/presenter/remoteControlPresenter/interface.ts
index ce5b57332b..7054d2a068 100644
--- a/src/main/presenter/remoteControlPresenter/interface.ts
+++ b/src/main/presenter/remoteControlPresenter/interface.ts
@@ -2,7 +2,6 @@ import type {
DiscordRemoteSettings,
FeishuRemoteSettings,
IConfigPresenter,
- IAgentSessionPresenter,
IFilePresenter,
IRemoteControlPresenter,
QQBotRemoteSettings,
@@ -11,12 +10,52 @@ import type {
TelegramRemoteSettings,
WeixinIlinkRemoteSettings
} from '@shared/presenter'
+import type {
+ ChatMessageRecord,
+ CreateDetachedSessionInput,
+ MessageStartResult,
+ SendMessageInput,
+ SessionWithState,
+ ToolInteractionResponse,
+ ToolInteractionResult
+} from '@shared/types/agent-interface'
+import type { SearchResult } from '@shared/types/core/search'
import type { AgentManagerGenerationPort } from '@/agent/manager/agentManager'
import type { CronJobRemoteDeliveryPort } from '../cronJobs/deliveryRouter'
+export interface RemoteSessionLifecyclePort {
+ createDetachedSession(input: CreateDetachedSessionInput): Promise
+}
+
+export interface RemoteSessionTurnPort {
+ sendMessage(sessionId: string, content: string | SendMessageInput): Promise
+ respondToolInteraction(
+ sessionId: string,
+ messageId: string,
+ toolCallId: string,
+ response: ToolInteractionResponse
+ ): Promise
+}
+
+export interface RemoteSessionAssignmentPort {
+ setSessionModel(sessionId: string, providerId: string, modelId: string): Promise
+}
+
+export interface RemoteSessionProjectionPort {
+ getSession(sessionId: string): Promise
+ listSessions(filters?: { agentId?: string }): Promise
+ getMessages(sessionId: string): Promise
+ getMessage(messageId: string): Promise
+ getSearchResults(messageId: string, searchId?: string): Promise
+ activate(webContentsId: number, sessionId: string): Promise
+}
+
export interface RemoteControlPresenterDeps {
configPresenter: IConfigPresenter
- agentSessionPresenter: IAgentSessionPresenter
+ lifecycle: RemoteSessionLifecyclePort
+ turn: RemoteSessionTurnPort
+ assignment: RemoteSessionAssignmentPort
+ projection: RemoteSessionProjectionPort
filePresenter?: IFilePresenter
agentManager: AgentManagerGenerationPort
windowPresenter: IWindowPresenter
diff --git a/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts b/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts
index c418e5c2f8..c8120552ca 100644
--- a/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts
+++ b/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts
@@ -13,7 +13,6 @@ import type {
import type { SearchResult } from '@shared/types/core/search'
import type {
IConfigPresenter,
- IAgentSessionPresenter,
IFilePresenter,
ITabPresenter,
IWindowPresenter
@@ -48,6 +47,12 @@ import {
} from './remoteBlockRenderer'
import { RemoteBindingStore } from './remoteBindingStore'
import { collectPendingInteraction } from './remoteInteraction'
+import type {
+ RemoteSessionAssignmentPort,
+ RemoteSessionLifecyclePort,
+ RemoteSessionProjectionPort,
+ RemoteSessionTurnPort
+} from '../interface'
const sleep = async (ms: number): Promise => {
await new Promise((resolve) => setTimeout(resolve, ms))
@@ -425,7 +430,10 @@ export type RemoteOpenSessionResult =
type RemoteConversationRunnerDeps = {
configPresenter: IConfigPresenter
- agentSessionPresenter: IAgentSessionPresenter
+ lifecycle: RemoteSessionLifecyclePort
+ turn: RemoteSessionTurnPort
+ assignment: RemoteSessionAssignmentPort
+ projection: RemoteSessionProjectionPort
filePresenter?: IFilePresenter
agentManager: AgentManagerGenerationPort
windowPresenter: IWindowPresenter
@@ -459,7 +467,7 @@ export class RemoteConversationRunner {
throw new Error('ACP remote agent requires a channel default directory.')
}
- const session = await this.deps.agentSessionPresenter.createDetachedSession({
+ const session = await this.deps.lifecycle.createDetachedSession({
title: title?.trim() || 'New Chat',
agentId,
...(projectDir ? { projectDir } : {}),
@@ -485,7 +493,7 @@ export class RemoteConversationRunner {
return null
}
- const session = await this.deps.agentSessionPresenter.getSession(binding.sessionId)
+ const session = await this.deps.projection.getSession(binding.sessionId)
if (!session) {
this.bindingStore.clearBinding(endpointKey)
return null
@@ -517,7 +525,7 @@ export class RemoteConversationRunner {
async listSessions(endpointKey: string): Promise {
const agentId = await this.resolveSessionListAgentId(endpointKey)
- const sessions = await this.deps.agentSessionPresenter.getSessionList({
+ const sessions = await this.deps.projection.listSessions({
agentId
})
const sorted = [...sessions]
@@ -545,7 +553,7 @@ export class RemoteConversationRunner {
throw new Error('Session index is out of range.')
}
- const session = await this.deps.agentSessionPresenter.getSession(sessionId)
+ const session = await this.deps.projection.getSession(sessionId)
if (!session) {
throw new Error('Selected session no longer exists.')
}
@@ -587,7 +595,7 @@ export class RemoteConversationRunner {
throw new Error('No bound session. Send a message, /new, or /use first.')
}
- return await this.deps.agentSessionPresenter.setSessionModel(session.id, providerId, modelId)
+ return await this.deps.assignment.setSessionModel(session.id, providerId, modelId)
}
async listAvailableAgents(): Promise {
@@ -661,7 +669,7 @@ export class RemoteConversationRunner {
const session = await this.ensureBoundSession(endpointKey, bindingMeta, {
requireCurrentDefaultAgent: true
})
- const beforeMessages = await this.deps.agentSessionPresenter.getMessages(session.id)
+ const beforeMessages = await this.deps.projection.getMessages(session.id)
const lastOrderSeq = beforeMessages.at(-1)?.orderSeq ?? 0
const previousActiveEventId =
this.deps.agentManager.getActiveGeneration(toAppSessionId(session.id))?.eventId ?? null
@@ -675,7 +683,7 @@ export class RemoteConversationRunner {
const text = input.text.trim() || (files.length > 0 ? 'Please use the attached files.' : '')
const messageInput: string | SendMessageInput = files.length > 0 ? { text, files } : text
- await this.deps.agentSessionPresenter.sendMessage(session.id, messageInput)
+ await this.deps.turn.sendMessage(session.id, messageInput)
const seededMessage = await this.waitForAssistantMessage(session.id, lastOrderSeq, 800, {
ignoreMessageId: previousActiveEventId
@@ -723,7 +731,7 @@ export class RemoteConversationRunner {
throw new Error('No pending interaction was found.')
}
- const result = await this.deps.agentSessionPresenter.respondToolInteraction(
+ const result = await this.deps.turn.respondToolInteraction(
session.id,
interaction.messageId,
interaction.toolCallId,
@@ -789,7 +797,7 @@ export class RemoteConversationRunner {
}
}
- await this.deps.agentSessionPresenter.activateSession(window.webContents.id, session.id)
+ await this.deps.projection.activate(window.webContents.id, session.id)
this.deps.windowPresenter.show(window.id, true)
return {
status: 'ok',
@@ -1092,7 +1100,7 @@ export class RemoteConversationRunner {
ignoreMessageId: string | null
}
): Promise {
- const session = await this.deps.agentSessionPresenter.getSession(sessionId)
+ const session = await this.deps.projection.getSession(sessionId)
if (!session) {
this.bindingStore.clearBinding(endpointKey)
return {
@@ -1204,12 +1212,8 @@ export class RemoteConversationRunner {
}
private async loadSearchResults(messageId: string, searchId?: string): Promise {
- if (typeof this.deps.agentSessionPresenter.getSearchResults !== 'function') {
- return []
- }
-
try {
- return await this.deps.agentSessionPresenter.getSearchResults(messageId, searchId)
+ return await this.deps.projection.getSearchResults(messageId, searchId)
} catch (error) {
console.warn('[RemoteConversationRunner] Failed to load search results:', {
messageId,
@@ -1313,7 +1317,7 @@ export class RemoteConversationRunner {
while (Date.now() < deadline) {
const activeGeneration = this.deps.agentManager.getActiveGeneration(toAppSessionId(sessionId))
if (activeGeneration?.eventId && activeGeneration.eventId !== options?.ignoreMessageId) {
- const message = await this.deps.agentSessionPresenter.getMessage(activeGeneration.eventId)
+ const message = await this.deps.projection.getMessage(activeGeneration.eventId)
if (message?.role === 'assistant') {
return message
}
@@ -1349,7 +1353,7 @@ export class RemoteConversationRunner {
continue
}
- const message = await this.deps.agentSessionPresenter.getMessage(messageId)
+ const message = await this.deps.projection.getMessage(messageId)
if (message?.role === 'assistant') {
return message
}
@@ -1367,7 +1371,7 @@ export class RemoteConversationRunner {
afterOrderSeq: number,
ignoreMessageId?: string | null
): Promise {
- const messages = await this.deps.agentSessionPresenter.getMessages(sessionId)
+ const messages = await this.deps.projection.getMessages(sessionId)
const assistants = messages.filter(
(message) =>
message.role === 'assistant' &&
@@ -1400,7 +1404,7 @@ export class RemoteConversationRunner {
private async getCurrentPendingInteractionDetails(
sessionId: string
): Promise {
- const messages = await this.deps.agentSessionPresenter.getMessages(sessionId)
+ const messages = await this.deps.projection.getMessages(sessionId)
const assistants = [...messages]
.filter((message) => message.role === 'assistant')
.sort((left, right) => right.orderSeq - left.orderSeq)
diff --git a/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts b/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts
new file mode 100644
index 0000000000..93e23cd534
--- /dev/null
+++ b/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts
@@ -0,0 +1,602 @@
+import { toAppSessionId } from '@/agent/shared/agentSessionIds'
+import type {
+ AgentTransferBlockReason,
+ AgentTransferImpact,
+ AgentTransferImpactSample,
+ PermissionMode,
+ SessionGenerationSettings,
+ SessionRecord,
+ SessionWithState
+} from '@shared/types/agent-interface'
+import type { AcpConfigState } from '@shared/presenter'
+import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias'
+import type {
+ SessionAgentAssignmentPort,
+ SessionAssignmentAcpControlPort,
+ SessionAssignmentEnvironmentPort,
+ SessionAssignmentPolicyPort,
+ SessionAssignmentProjectionPort,
+ SessionAssignmentRuntimePort,
+ SessionAssignmentStorePort,
+ SessionAssignmentWorkdirPort,
+ SessionLifecycleDeletionPort
+} from './ports'
+import { normalizeDisabledAgentTools } from '@/agent/shared/agentSessionNormalization'
+
+export interface SessionAgentAssignmentDependencies {
+ sessions: SessionAssignmentStorePort
+ runtime: SessionAssignmentRuntimePort
+ policy: SessionAssignmentPolicyPort
+ projection: SessionAssignmentProjectionPort
+ deletion: SessionLifecycleDeletionPort
+ environment: SessionAssignmentEnvironmentPort
+ acp: SessionAssignmentAcpControlPort
+}
+
+export class SessionAgentAssignmentCoordinator
+ implements SessionAgentAssignmentPort, SessionAssignmentWorkdirPort
+{
+ constructor(private readonly dependencies: SessionAgentAssignmentDependencies) {}
+
+ async mergeSubagentTape(
+ parentSessionId: string,
+ childSessionId: string,
+ meta: Record = {}
+ ): Promise {
+ this.requireChildSession(parentSessionId, childSessionId)
+ const resolved = this.dependencies.runtime.resolveSubagentFacet(toAppSessionId(parentSessionId))
+ await resolved.facet.mergeTape(
+ toAppSessionId(parentSessionId),
+ toAppSessionId(childSessionId),
+ meta
+ )
+ }
+
+ async discardSubagentTape(
+ parentSessionId: string,
+ childSessionId: string,
+ meta: Record = {}
+ ): Promise {
+ this.requireChildSession(parentSessionId, childSessionId)
+ const resolved = this.dependencies.runtime.resolveSubagentFacet(toAppSessionId(parentSessionId))
+ await resolved.facet.discardTape(
+ toAppSessionId(parentSessionId),
+ toAppSessionId(childSessionId),
+ meta
+ )
+ }
+
+ async getAgentTransferImpact(agentId: string): Promise {
+ const normalizedAgentId = agentId.trim()
+ if (!normalizedAgentId) {
+ throw new Error('Agent id is required.')
+ }
+
+ const sessions = this.dependencies.sessions.list({
+ agentId: normalizedAgentId,
+ includeSubagents: true
+ })
+ const samples: AgentTransferImpactSample[] = []
+ let emptyDrafts = 0
+ let movableSessions = 0
+ let blockedSessions = 0
+
+ for (const session of sessions) {
+ const assessment = await this.assessTransferSession(session)
+ if (assessment.isEmptyDraft) emptyDrafts += 1
+ if (assessment.blockReason) {
+ blockedSessions += 1
+ } else if (!assessment.isEmptyDraft) {
+ movableSessions += 1
+ }
+
+ if (samples.length < 6 && (!assessment.isEmptyDraft || assessment.blockReason)) {
+ samples.push({
+ id: session.id,
+ title: session.title,
+ sessionKind: session.sessionKind,
+ isDraft: Boolean(session.isDraft),
+ projectDir: session.projectDir,
+ status: assessment.status,
+ blockReason: assessment.blockReason
+ })
+ }
+ }
+
+ return {
+ agentId: normalizedAgentId,
+ totalSessions: sessions.length,
+ regularSessions: sessions.filter((session) => session.sessionKind === 'regular').length,
+ subagentSessions: sessions.filter((session) => session.sessionKind === 'subagent').length,
+ emptyDrafts,
+ movableSessions,
+ blockedSessions,
+ samples
+ }
+ }
+
+ async moveAgentSessions(
+ fromAgentId: string,
+ toAgentId: string
+ ): Promise<{ movedSessionIds: string[]; deletedSessionIds: string[] }> {
+ const sourceAgentId = fromAgentId.trim()
+ const targetAgentId = toAgentId.trim()
+ if (!sourceAgentId || !targetAgentId) {
+ throw new Error('Source and target agent ids are required.')
+ }
+ if (sourceAgentId === targetAgentId) {
+ throw new Error('Source and target agents cannot be the same.')
+ }
+ await this.dependencies.policy.resolveTransferTarget(targetAgentId, null)
+
+ const sessions = this.dependencies.sessions.list({
+ agentId: sourceAgentId,
+ includeSubagents: true
+ })
+ const transferSessionIds: string[] = []
+ const emptyDraftSessionIds: string[] = []
+ const movedSessionIds: string[] = []
+ const deletedSessionIds: string[] = []
+ const deletedSessionIdSet = new Set()
+
+ for (const session of sessions) {
+ const assessment = await this.assessTransferSession(session)
+ if (assessment.blockReason) {
+ throw new Error(`Session ${session.id} cannot be moved: ${assessment.blockReason}`)
+ }
+ if (assessment.isEmptyDraft) {
+ emptyDraftSessionIds.push(session.id)
+ continue
+ }
+
+ await this.dependencies.policy.resolveTransferTarget(targetAgentId, session.projectDir)
+ transferSessionIds.push(session.id)
+ }
+
+ try {
+ for (const sessionId of transferSessionIds) {
+ if (deletedSessionIdSet.has(sessionId)) continue
+ if (!this.dependencies.sessions.get(sessionId)) {
+ throw new Error(`Session ${sessionId} is no longer available.`)
+ }
+ await this.moveSessionToAgentInternal(sessionId, targetAgentId, true)
+ movedSessionIds.push(sessionId)
+ }
+
+ for (const sessionId of emptyDraftSessionIds) {
+ if (deletedSessionIdSet.has(sessionId)) continue
+ if (!this.dependencies.sessions.get(sessionId)) {
+ throw new Error(`Session ${sessionId} is no longer available.`)
+ }
+ const deleted = await this.dependencies.deletion.deleteSessionTree(sessionId)
+ deleted.forEach((deletedSessionId) => deletedSessionIdSet.add(deletedSessionId))
+ deletedSessionIds.push(...deleted)
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ const partialCounts = [
+ movedSessionIds.length > 0 ? `${movedSessionIds.length} moved` : '',
+ deletedSessionIds.length > 0 ? `${deletedSessionIds.length} deleted` : ''
+ ].filter(Boolean)
+
+ if (partialCounts.length > 0) {
+ this.notifyTransferResults(movedSessionIds, deletedSessionIds)
+ throw new Error(`${message} Partial transfer completed: ${partialCounts.join(', ')}.`)
+ }
+ throw error
+ }
+
+ this.notifyTransferResults(movedSessionIds, deletedSessionIds)
+ return { movedSessionIds, deletedSessionIds }
+ }
+
+ async deleteAgentSessions(agentId: string): Promise {
+ const normalizedAgentId = agentId.trim()
+ if (!normalizedAgentId) {
+ throw new Error('Agent id is required.')
+ }
+
+ const sessions = this.dependencies.sessions.list({
+ agentId: normalizedAgentId,
+ includeSubagents: true
+ })
+ const deletedSessionIds: string[] = []
+ const deletedSessionIdSet = new Set()
+
+ for (const session of sessions) {
+ const assessment = await this.assessTransferSession(session)
+ if (assessment.blockReason) {
+ throw new Error(`Session ${session.id} cannot be deleted: ${assessment.blockReason}`)
+ }
+ }
+
+ for (const session of sessions) {
+ if (deletedSessionIdSet.has(session.id) || !this.dependencies.sessions.get(session.id)) {
+ continue
+ }
+ const deleted = await this.dependencies.deletion.deleteSessionTree(session.id)
+ deleted.forEach((sessionId) => deletedSessionIdSet.add(sessionId))
+ deletedSessionIds.push(...deleted)
+ }
+
+ if (deletedSessionIds.length > 0) {
+ this.dependencies.projection.notify({
+ sessionIds: deletedSessionIds,
+ reason: 'deleted'
+ })
+ }
+ return deletedSessionIds
+ }
+
+ async moveSessionToAgent(sessionId: string, toAgentId: string): Promise {
+ const updated = await this.moveSessionToAgentInternal(sessionId, toAgentId)
+ this.dependencies.projection.notify({ sessionIds: [sessionId], reason: 'updated' })
+ return updated
+ }
+
+ async getAcpSessionCommands(sessionId: string): Promise<
+ Array<{
+ name: string
+ description: string
+ input?: { hint: string } | null
+ }>
+ > {
+ if (!this.dependencies.sessions.get(sessionId)) return []
+ const { handle } = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId))
+ if (handle.kind === 'acp') return await handle.acp.getCommands()
+ if ((await handle.snapshot())?.providerId !== 'acp') return []
+ return await this.dependencies.acp.getAcpSessionCommands(sessionId)
+ }
+
+ async getAcpSessionConfigOptions(sessionId: string): Promise {
+ if (!this.dependencies.sessions.get(sessionId)) return null
+ const { handle } = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId))
+ if (handle.kind === 'acp') return await handle.acp.getConfigOptions()
+ if ((await handle.snapshot())?.providerId !== 'acp') return null
+ return await this.dependencies.acp.getAcpSessionConfigOptions(sessionId)
+ }
+
+ async setAcpSessionConfigOption(
+ sessionId: string,
+ configId: string,
+ value: string | boolean
+ ): Promise {
+ if (!this.dependencies.sessions.get(sessionId)) {
+ throw new Error(`Session not found: ${sessionId}`)
+ }
+ const { handle } = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId))
+ if (handle.kind === 'acp') return await handle.acp.setConfigOption(configId, value)
+ if ((await handle.snapshot())?.providerId !== 'acp') {
+ throw new Error('ACP session config options are only available for ACP sessions.')
+ }
+ return await this.dependencies.acp.setAcpSessionConfigOption(sessionId, configId, value)
+ }
+
+ async getPermissionMode(sessionId: string): Promise {
+ this.requireSession(sessionId)
+ return await this.dependencies.runtime
+ .resolveSession(toAppSessionId(sessionId))
+ .handle.settings.getPermissionMode()
+ }
+
+ async setPermissionMode(sessionId: string, mode: PermissionMode): Promise {
+ this.requireSession(sessionId)
+ await this.dependencies.runtime
+ .resolveSession(toAppSessionId(sessionId))
+ .handle.settings.setPermissionMode(mode)
+ }
+
+ async setSessionSubagentEnabled(sessionId: string, enabled: boolean): Promise {
+ const session = this.requireSession(sessionId)
+ if (session.sessionKind !== 'regular') {
+ throw new Error('Only regular sessions can change subagent state.')
+ }
+
+ if (this.dependencies.runtime.getSessionAgentKind(toAppSessionId(sessionId)) !== 'deepchat') {
+ throw new Error('Only DeepChat sessions can change subagent state.')
+ }
+
+ this.dependencies.sessions.update(sessionId, { subagentEnabled: enabled })
+ if (!this.dependencies.sessions.get(sessionId)) {
+ throw new Error(`Session not found after update: ${sessionId}`)
+ }
+
+ this.dependencies.projection.notify({ sessionIds: [sessionId], reason: 'updated' })
+ const materialized = await this.dependencies.projection.materialize(sessionId)
+ if (!materialized) {
+ throw new Error(`Failed to build session state for sessionId: ${sessionId}`)
+ }
+ return materialized
+ }
+
+ async setSessionModel(
+ sessionId: string,
+ providerId: string,
+ modelId: string
+ ): Promise {
+ const session = this.requireSession(sessionId)
+ const nextProviderId = providerId?.trim()
+ const nextModelId = modelId?.trim()
+ if (!nextProviderId || !nextModelId) {
+ throw new Error('setSessionModel requires providerId and modelId.')
+ }
+
+ const { handle } = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId))
+ if (handle.kind !== 'deepchat') throw new Error('ACP session model is locked.')
+ await handle.deepchat.setModel(nextProviderId, nextModelId)
+ const state = await handle.snapshot()
+ const updated: SessionWithState = {
+ ...session,
+ status: state?.status ?? 'idle',
+ providerId: state?.providerId ?? nextProviderId,
+ modelId: state?.modelId ?? nextModelId
+ }
+ this.dependencies.projection.notify({ sessionIds: [sessionId], reason: 'updated' })
+ return updated
+ }
+
+ async setSessionProjectDir(
+ sessionId: string,
+ projectDir: string | null
+ ): Promise {
+ const session = this.requireSession(sessionId)
+ const { handle } = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId))
+ const state = await handle.snapshot()
+ const providerId = state?.providerId?.trim() || (handle.kind === 'acp' ? 'acp' : '')
+ const normalizedProjectDir = projectDir?.trim() || null
+ this.assertAcpSessionHasWorkdir(providerId, normalizedProjectDir)
+
+ this.dependencies.sessions.update(sessionId, { projectDir: normalizedProjectDir })
+ if (normalizedProjectDir) this.dependencies.environment.syncPath(normalizedProjectDir)
+ await handle.settings.setProjectDir(normalizedProjectDir)
+ await this.syncAcpSessionWorkdir(providerId, sessionId, session.agentId, normalizedProjectDir)
+
+ if (!this.dependencies.sessions.get(sessionId)) {
+ throw new Error(`Session not found after update: ${sessionId}`)
+ }
+
+ this.dependencies.projection.notify({ sessionIds: [sessionId], reason: 'updated' })
+ const materialized = await this.dependencies.projection.materialize(sessionId)
+ if (!materialized) {
+ throw new Error(`Failed to build session state after project update: ${sessionId}`)
+ }
+ return materialized
+ }
+
+ async getSessionGenerationSettings(sessionId: string): Promise {
+ this.requireSession(sessionId)
+ return await this.dependencies.runtime
+ .resolveSession(toAppSessionId(sessionId))
+ .handle.settings.getGenerationSettings()
+ }
+
+ async getSessionDisabledAgentTools(sessionId: string): Promise {
+ this.requireSession(sessionId)
+ return this.dependencies.sessions.getDisabledAgentTools(sessionId)
+ }
+
+ async updateSessionDisabledAgentTools(
+ sessionId: string,
+ disabledAgentTools: string[]
+ ): Promise {
+ this.requireSession(sessionId)
+ const normalized = normalizeDisabledAgentTools(disabledAgentTools)
+ this.dependencies.sessions.updateDisabledAgentTools(sessionId, normalized)
+
+ const { handle } = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId))
+ if (handle.kind === 'deepchat') handle.deepchat.invalidateSystemPromptCache()
+ return normalized
+ }
+
+ async updateSessionGenerationSettings(
+ sessionId: string,
+ settings: Partial
+ ): Promise {
+ this.requireSession(sessionId)
+ return await this.dependencies.runtime
+ .resolveSession(toAppSessionId(sessionId))
+ .handle.settings.updateGenerationSettings(settings)
+ }
+
+ assertAcpSessionHasWorkdir(providerId: string, projectDir: string | null): void {
+ this.dependencies.policy.assertAcpSessionHasWorkdir(providerId, projectDir)
+ }
+
+ async syncAcpSessionWorkdir(
+ providerId: string,
+ sessionId: string,
+ agentId: string,
+ projectDir?: string | null
+ ): Promise {
+ if (providerId !== 'acp') return
+ const normalizedProjectDir = projectDir?.trim()
+ if (!normalizedProjectDir) return
+
+ try {
+ const { handle } = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId))
+ if (handle.kind === 'acp') {
+ await handle.acp.updateWorkdir(normalizedProjectDir)
+ return
+ }
+ await this.dependencies.acp.setAcpWorkdir(
+ sessionId,
+ resolveAcpAgentAlias(agentId),
+ normalizedProjectDir
+ )
+ } catch (error) {
+ console.warn('[SessionAgentAssignmentCoordinator] Failed to sync ACP workdir:', {
+ sessionId,
+ agentId,
+ projectDir: normalizedProjectDir,
+ error
+ })
+ throw error
+ }
+ }
+
+ async prepareDirectAcpSession(sessionId: string): Promise {
+ const { handle } = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId))
+ if (handle.kind !== 'acp') {
+ throw new Error(`Session ${sessionId} is not a direct ACP session.`)
+ }
+ await handle.acp.prepare()
+ }
+
+ async clearCompatibilityAcpSession(sessionId: string): Promise {
+ await this.dependencies.acp.clearAcpSession(sessionId)
+ }
+
+ private async assessTransferSession(session: SessionRecord): Promise<{
+ status: SessionWithState['status']
+ isEmptyDraft: boolean
+ blockReason?: AgentTransferBlockReason
+ }> {
+ const { handle, facet } = this.dependencies.runtime.resolveTransferSource(
+ toAppSessionId(session.id)
+ )
+ const state = await handle.snapshot()
+ const status = state?.status ?? 'idle'
+ let hasMessages = true
+ try {
+ hasMessages = await facet.hasMessages(toAppSessionId(session.id))
+ } catch (error) {
+ console.warn(
+ `[SessionAgentAssignmentCoordinator] Failed to inspect messages for session=${session.id}:`,
+ error
+ )
+ }
+
+ let hasPendingInput = false
+ try {
+ hasPendingInput = (await facet.listPendingInputs(toAppSessionId(session.id))).length > 0
+ } catch (error) {
+ console.warn(
+ `[SessionAgentAssignmentCoordinator] Failed to inspect pending input for session=${session.id}:`,
+ error
+ )
+ hasPendingInput = true
+ }
+
+ const hasSubagentChildren =
+ session.sessionKind === 'regular' &&
+ this.dependencies.sessions.list({
+ includeSubagents: true,
+ parentSessionId: session.id
+ }).length > 0
+ const isEmptyDraft = Boolean(session.isDraft) && !hasMessages && !hasSubagentChildren
+
+ if (status === 'generating') return { status, isEmptyDraft, blockReason: 'active' }
+ if (hasPendingInput) return { status, isEmptyDraft, blockReason: 'pending-input' }
+ return { status, isEmptyDraft }
+ }
+
+ private async moveSessionToAgentInternal(
+ sessionId: string,
+ toAgentId: string,
+ allowSubagent: boolean = false
+ ): Promise {
+ const session = this.requireSession(sessionId)
+ if (!allowSubagent && session.sessionKind !== 'regular') {
+ throw new Error('Only regular conversations can be moved from the conversation menu.')
+ }
+
+ const targetAgentId = toAgentId.trim()
+ if (!targetAgentId) throw new Error('Target agent id is required.')
+ if (session.agentId === targetAgentId) {
+ throw new Error('Conversation is already assigned to the selected agent.')
+ }
+
+ const assessment = await this.assessTransferSession(session)
+ if (assessment.blockReason) {
+ throw new Error(`Session ${sessionId} cannot be moved: ${assessment.blockReason}`)
+ }
+
+ const target = await this.dependencies.policy.resolveTransferTarget(
+ targetAgentId,
+ session.projectDir
+ )
+ const source = this.dependencies.runtime.resolveTransferSource(toAppSessionId(sessionId))
+ const sourceState = await source.handle.snapshot()
+ const previousDirectAcp = source.handle.kind === 'acp'
+ const previousCompatibilityAcp =
+ source.handle.kind === 'deepchat' && sourceState?.providerId === 'acp'
+ const { facet: transferTarget } = this.dependencies.runtime.resolveDeepChatTransferTarget(
+ target.agentId
+ )
+
+ await transferTarget.setSessionAgentContext(toAppSessionId(sessionId), {
+ agentId: target.agentId,
+ providerId: target.providerId,
+ modelId: target.modelId,
+ projectDir: target.projectDir,
+ permissionMode: target.permissionMode,
+ generationSettings: target.generationSettings
+ })
+
+ this.dependencies.sessions.updateAgentId(sessionId, target.agentId)
+ this.dependencies.sessions.update(sessionId, {
+ projectDir: target.projectDir,
+ subagentEnabled: session.sessionKind === 'regular' ? target.subagentEnabled : false
+ })
+ this.dependencies.sessions.updateDisabledAgentTools(sessionId, target.disabledAgentTools)
+ await this.syncAcpSessionWorkdir(
+ target.providerId,
+ sessionId,
+ target.agentId,
+ target.projectDir
+ )
+
+ if (!this.dependencies.sessions.get(sessionId)) {
+ throw new Error(`Session not found after transfer: ${sessionId}`)
+ }
+ const materialized = await this.dependencies.projection.materialize(sessionId)
+ if (!materialized) {
+ throw new Error(`Failed to build session state after transfer: ${sessionId}`)
+ }
+
+ if (previousDirectAcp && source.closeRuntime) {
+ try {
+ await source.closeRuntime()
+ } catch (error) {
+ console.warn(
+ `[SessionAgentAssignmentCoordinator] Failed to close direct ACP runtime after transfer ${sessionId}:`,
+ error
+ )
+ }
+ } else if (previousCompatibilityAcp) {
+ try {
+ await this.clearCompatibilityAcpSession(sessionId)
+ } catch (error) {
+ console.warn(
+ `[SessionAgentAssignmentCoordinator] Failed to clear stale ACP binding after transfer ${sessionId}:`,
+ error
+ )
+ }
+ }
+
+ return materialized
+ }
+
+ private requireSession(sessionId: string): SessionRecord {
+ const session = this.dependencies.sessions.get(sessionId)
+ if (!session) throw new Error(`Session not found: ${sessionId}`)
+ return session
+ }
+
+ private requireChildSession(parentSessionId: string, childSessionId: string): void {
+ this.requireSession(parentSessionId)
+ const child = this.requireSession(childSessionId)
+ if (child.parentSessionId !== parentSessionId) {
+ throw new Error(`Session ${childSessionId} is not a child of ${parentSessionId}.`)
+ }
+ }
+
+ private notifyTransferResults(movedSessionIds: string[], deletedSessionIds: string[]): void {
+ if (movedSessionIds.length > 0) {
+ this.dependencies.projection.notify({ sessionIds: movedSessionIds, reason: 'updated' })
+ }
+ if (deletedSessionIds.length > 0) {
+ this.dependencies.projection.notify({ sessionIds: deletedSessionIds, reason: 'deleted' })
+ }
+ }
+}
diff --git a/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts b/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts
new file mode 100644
index 0000000000..a1c4c28c97
--- /dev/null
+++ b/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts
@@ -0,0 +1,202 @@
+import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias'
+import type {
+ DeepChatAgentConfig,
+ PermissionMode,
+ SessionGenerationSettings
+} from '@shared/types/agent-interface'
+import type {
+ CreateAssignmentInput,
+ ResolvedSessionAssignment,
+ ResolvedSubagentAssignment,
+ ResolvedTransferTarget,
+ SessionAssignmentCatalogPort,
+ SessionAssignmentConfigPort,
+ SessionAssignmentPolicyPort,
+ SubagentAssignmentInput
+} from './ports'
+import {
+ normalizeActiveSkills,
+ normalizeDisabledAgentTools
+} from '@/agent/shared/agentSessionNormalization'
+
+const normalizePermissionMode = (mode: PermissionMode | null | undefined): PermissionMode =>
+ mode === 'default' || mode === 'auto_approve' ? mode : 'full_access'
+
+export class SessionAgentAssignmentPolicy implements SessionAssignmentPolicyPort {
+ constructor(
+ private readonly catalog: SessionAssignmentCatalogPort,
+ private readonly config: SessionAssignmentConfigPort
+ ) {}
+
+ async resolveCreateAssignment(input: CreateAssignmentInput): Promise {
+ const descriptor = this.catalog.resolveAgent(input.agentId)
+ const agentConfig =
+ descriptor.kind === 'deepchat'
+ ? await this.config.resolveDeepChatAgentConfig(descriptor.id)
+ : null
+ const projectDir = this.resolveProjectDir(input, agentConfig?.defaultProjectPath)
+ const defaultModel = this.config.getDefaultModel()
+ const providerId =
+ descriptor.kind === 'acp'
+ ? 'acp'
+ : (input.providerId ??
+ agentConfig?.defaultModelPreset?.providerId ??
+ defaultModel?.providerId ??
+ '')
+ const modelId =
+ descriptor.kind === 'acp'
+ ? descriptor.id
+ : (input.modelId ?? agentConfig?.defaultModelPreset?.modelId ?? defaultModel?.modelId ?? '')
+
+ if (!providerId || !modelId) {
+ throw new Error('No provider or model configured. Please set a default model in settings.')
+ }
+ this.assertAcpSessionHasWorkdir(providerId, projectDir)
+
+ return {
+ agentId: descriptor.id,
+ agentType: descriptor.kind,
+ providerId,
+ modelId,
+ projectDir,
+ permissionMode:
+ input.permissionMode !== undefined
+ ? normalizePermissionMode(input.permissionMode)
+ : normalizePermissionMode(agentConfig?.permissionMode),
+ generationSettings: this.mergeDefaultGenerationSettings(
+ agentConfig,
+ input.generationSettings
+ ),
+ disabledAgentTools:
+ descriptor.kind === 'deepchat'
+ ? normalizeDisabledAgentTools(input.disabledAgentTools ?? agentConfig?.disabledAgentTools)
+ : [],
+ subagentEnabled:
+ descriptor.kind === 'deepchat'
+ ? (input.subagentEnabled ?? agentConfig?.subagentEnabled ?? false)
+ : false
+ }
+ }
+
+ resolveAcpDraftAssignment(
+ agentId: string,
+ permissionMode?: PermissionMode
+ ): { agentId: string; permissionMode: PermissionMode } {
+ const descriptor = this.catalog.resolveAgent(agentId)
+ if (descriptor.kind !== 'acp') {
+ throw new Error(`Agent ${agentId} is not an ACP agent.`)
+ }
+ return {
+ agentId: descriptor.id,
+ permissionMode: normalizePermissionMode(permissionMode)
+ }
+ }
+
+ async resolveSubagentAssignment(
+ input: SubagentAssignmentInput
+ ): Promise {
+ let descriptor: { id: string; kind: 'deepchat' | 'acp' }
+ try {
+ descriptor = this.catalog.resolveAgent(resolveAcpAgentAlias(input.agentId.trim()))
+ } catch {
+ throw new Error(`Agent ${input.agentId} is not a valid subagent target.`)
+ }
+
+ if (descriptor.kind === 'acp') {
+ this.assertAcpSessionHasWorkdir('acp', input.projectDir)
+ return {
+ agentId: descriptor.id,
+ targetAgentId: input.targetAgentId?.trim() ? descriptor.id : null,
+ providerId: 'acp',
+ modelId: descriptor.id,
+ generationSettings: { systemPrompt: '' },
+ disabledAgentTools: [],
+ activeSkills: []
+ }
+ }
+
+ this.assertAcpSessionHasWorkdir(input.providerId, input.projectDir)
+
+ return {
+ agentId: descriptor.id,
+ targetAgentId: input.targetAgentId?.trim() ? descriptor.id : null,
+ providerId: input.providerId,
+ modelId: input.modelId,
+ generationSettings: input.generationSettings,
+ disabledAgentTools: normalizeDisabledAgentTools(input.disabledAgentTools),
+ activeSkills: normalizeActiveSkills(input.activeSkills)
+ }
+ }
+
+ async resolveTransferTarget(
+ targetAgentId: string,
+ currentProjectDir: string | null
+ ): Promise {
+ let descriptor: { id: string; kind: 'deepchat' | 'acp' }
+ try {
+ descriptor = this.catalog.resolveAgent(resolveAcpAgentAlias(targetAgentId.trim()))
+ } catch {
+ throw new Error(`Target agent not found: ${targetAgentId}`)
+ }
+ if (descriptor.kind === 'acp') {
+ throw new Error('Conversation history cannot be moved to ACP agents.')
+ }
+
+ const agentConfig = await this.config.resolveDeepChatAgentConfig(descriptor.id)
+ const defaultModel = this.config.getDefaultModel()
+ const providerId =
+ agentConfig?.defaultModelPreset?.providerId?.trim() || defaultModel?.providerId?.trim() || ''
+ const modelId =
+ agentConfig?.defaultModelPreset?.modelId?.trim() || defaultModel?.modelId?.trim() || ''
+ if (!providerId || !modelId) {
+ throw new Error('Target DeepChat agent does not have a default model.')
+ }
+ if (providerId.toLowerCase() === 'acp') {
+ throw new Error('Conversation history cannot be moved to ACP agents.')
+ }
+
+ return {
+ agentId: descriptor.id,
+ providerId,
+ modelId,
+ projectDir:
+ currentProjectDir?.trim() ||
+ agentConfig?.defaultProjectPath?.trim() ||
+ this.config.getDefaultProjectPath()?.trim() ||
+ null,
+ permissionMode: normalizePermissionMode(agentConfig?.permissionMode),
+ generationSettings: this.mergeDefaultGenerationSettings(agentConfig),
+ disabledAgentTools: normalizeDisabledAgentTools(agentConfig?.disabledAgentTools),
+ subagentEnabled: agentConfig?.subagentEnabled === true
+ }
+ }
+
+ assertAcpSessionHasWorkdir(providerId: string, projectDir: string | null): void {
+ if (providerId === 'acp' && !projectDir?.trim()) {
+ throw new Error('ACP agent requires selecting a workdir before sending messages.')
+ }
+ }
+
+ private resolveProjectDir(
+ input: CreateAssignmentInput,
+ agentDefaultProjectDir: string | null | undefined
+ ): string | null {
+ if (input.preserveExplicitNullProjectDir && input.projectDir === null) return null
+ return (
+ input.projectDir?.trim() ||
+ agentDefaultProjectDir?.trim() ||
+ this.config.getDefaultProjectPath()?.trim() ||
+ null
+ )
+ }
+
+ private mergeDefaultGenerationSettings(
+ config: DeepChatAgentConfig | null,
+ overrides?: Partial
+ ): Partial | undefined {
+ const defaults: Partial = {}
+ if (typeof config?.systemPrompt === 'string') defaults.systemPrompt = config.systemPrompt
+ const merged = { ...defaults, ...overrides }
+ return Object.keys(merged).length > 0 ? merged : undefined
+ }
+}
diff --git a/src/main/presenter/sessionApplication/lifecycleCoordinator.ts b/src/main/presenter/sessionApplication/lifecycleCoordinator.ts
new file mode 100644
index 0000000000..464eb16191
--- /dev/null
+++ b/src/main/presenter/sessionApplication/lifecycleCoordinator.ts
@@ -0,0 +1,501 @@
+import logger from '@shared/logger'
+import { toAppSessionId } from '@/agent/shared/agentSessionIds'
+import { normalizeCreateSessionInput } from '@/agent/shared/agentSessionNormalization'
+import type {
+ CreateDetachedSessionInput,
+ CreateSessionInput,
+ DeepChatSubagentMeta,
+ PermissionMode,
+ SessionRecord,
+ SessionWithState
+} from '@shared/types/agent-interface'
+import type {
+ SessionAssignmentPolicyPort,
+ SessionAssignmentWorkdirPort,
+ SessionInitialTurnPort,
+ SessionLifecycleDeletionPort,
+ SessionLifecyclePort,
+ SessionLifecycleProjectionPort,
+ SessionLifecycleRuntimeConfig,
+ SessionLifecycleRuntimePort,
+ SessionLifecycleSkillPort,
+ SessionLifecycleStorePort,
+ SessionLifecycleSubagentInput,
+ SessionLifecycleTranscriptPort
+} from './ports'
+
+const SUBAGENT_SESSION_INIT_MAX_ATTEMPTS = 2
+
+export interface SessionLifecycleCoordinatorDependencies {
+ sessions: SessionLifecycleStorePort
+ runtime: SessionLifecycleRuntimePort
+ transcript: SessionLifecycleTranscriptPort
+ skills: SessionLifecycleSkillPort
+ assignmentPolicy: SessionAssignmentPolicyPort
+ workdir: SessionAssignmentWorkdirPort
+ initialTurn: SessionInitialTurnPort
+ projection: SessionLifecycleProjectionPort
+ deletion: SessionLifecycleDeletionPort
+}
+
+export class SessionLifecycleCoordinator implements SessionLifecyclePort {
+ constructor(private readonly dependencies: SessionLifecycleCoordinatorDependencies) {}
+
+ async createSession(input: CreateSessionInput, webContentsId: number): Promise {
+ const assignment = await this.dependencies.assignmentPolicy.resolveCreateAssignment({
+ agentId: input.agentId || 'deepchat',
+ providerId: input.providerId,
+ modelId: input.modelId,
+ projectDir: input.projectDir,
+ permissionMode: input.permissionMode,
+ generationSettings: input.generationSettings,
+ disabledAgentTools: input.disabledAgentTools,
+ subagentEnabled: input.subagentEnabled,
+ preserveExplicitNullProjectDir: true
+ })
+ const {
+ agentId,
+ providerId,
+ modelId,
+ projectDir,
+ permissionMode,
+ generationSettings,
+ disabledAgentTools,
+ subagentEnabled
+ } = assignment
+ logger.info(
+ `[SessionLifecycleCoordinator] createSession agent=${agentId} webContentsId=${webContentsId}`
+ )
+ const normalizedInput = normalizeCreateSessionInput(input)
+ logger.info(`[SessionLifecycleCoordinator] resolved provider=${providerId} model=${modelId}`)
+
+ const title = normalizedInput.text.slice(0, 50) || 'New Chat'
+ const sessionId = this.dependencies.sessions.create(agentId, title, projectDir, {
+ isDraft: false,
+ disabledAgentTools,
+ subagentEnabled
+ })
+ logger.info(`[SessionLifecycleCoordinator] session created id=${sessionId}`)
+
+ try {
+ await this.initializeSessionRuntime(sessionId, {
+ agentId,
+ providerId,
+ modelId,
+ projectDir,
+ permissionMode,
+ ...(generationSettings ? { generationSettings } : {})
+ })
+ } catch (error) {
+ await this.cleanupFailedSessionInitialization(sessionId, providerId)
+ throw error
+ }
+ logger.info('[SessionLifecycleCoordinator] agent.initSession done')
+
+ this.dependencies.projection.bindWindow(webContentsId, sessionId)
+ this.dependencies.projection.notify({
+ sessionIds: [sessionId],
+ reason: 'created',
+ activeSessionId: sessionId,
+ webContentsId
+ })
+
+ const state = await this.dependencies.runtime.resolveSession(sessionId).snapshot()
+ const result: SessionWithState = {
+ id: sessionId,
+ agentId,
+ title,
+ projectDir,
+ isPinned: false,
+ isDraft: false,
+ sessionKind: 'regular',
+ parentSessionId: null,
+ subagentEnabled,
+ subagentMeta: null,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ status: state?.status ?? 'idle',
+ providerId: state?.providerId ?? providerId,
+ modelId: state?.modelId ?? modelId
+ }
+
+ this.dependencies.initialTurn.startInitialTurn({
+ sessionId,
+ content: normalizedInput,
+ projectDir,
+ initialTitle: title,
+ fallbackProviderId: providerId,
+ fallbackModelId: modelId
+ })
+ return result
+ }
+
+ async createDetachedSession(input: CreateDetachedSessionInput): Promise {
+ const title = input.title?.trim() || 'New Chat'
+ const {
+ agentId,
+ providerId,
+ modelId,
+ projectDir,
+ permissionMode,
+ generationSettings,
+ disabledAgentTools,
+ subagentEnabled
+ } = await this.dependencies.assignmentPolicy.resolveCreateAssignment({
+ agentId: input.agentId?.trim() || 'deepchat',
+ providerId: input.providerId,
+ modelId: input.modelId,
+ projectDir: input.projectDir,
+ permissionMode: input.permissionMode,
+ generationSettings: input.generationSettings,
+ disabledAgentTools: input.disabledAgentTools,
+ subagentEnabled: input.subagentEnabled,
+ preserveExplicitNullProjectDir: false
+ })
+
+ const sessionId = this.dependencies.sessions.create(agentId, title, projectDir, {
+ isDraft: false,
+ disabledAgentTools,
+ subagentEnabled,
+ metadata: input.metadata ?? null
+ })
+ try {
+ await this.initializeSessionRuntime(sessionId, {
+ agentId,
+ providerId,
+ modelId,
+ projectDir,
+ permissionMode,
+ generationSettings
+ })
+ } catch (error) {
+ await this.cleanupFailedSessionInitialization(sessionId, providerId)
+ throw error
+ }
+
+ if (input.activeSkills && input.activeSkills.length > 0) {
+ await this.dependencies.skills.setActiveSkills(sessionId, input.activeSkills)
+ }
+ this.dependencies.projection.notify({ sessionIds: [sessionId], reason: 'created' })
+
+ const state = await this.dependencies.runtime.resolveSession(sessionId).snapshot()
+ return {
+ id: sessionId,
+ agentId,
+ title,
+ projectDir,
+ isPinned: false,
+ isDraft: false,
+ sessionKind: 'regular',
+ parentSessionId: null,
+ subagentEnabled,
+ subagentMeta: null,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ ...(input.metadata ? { metadata: input.metadata } : {}),
+ status: state?.status ?? 'idle',
+ providerId: state?.providerId ?? providerId,
+ modelId: state?.modelId ?? modelId
+ }
+ }
+
+ async createSubagentSession(input: SessionLifecycleSubagentInput): Promise {
+ const parentSessionId = input.parentSessionId?.trim()
+ if (!parentSessionId) throw new Error('Subagent session requires a parentSessionId.')
+
+ const slotId = input.slotId?.trim()
+ if (!slotId) throw new Error('Subagent session requires a slotId.')
+
+ const displayName = input.displayName?.trim() || 'Subagent'
+ const agentId = input.agentId?.trim()
+ if (!agentId) throw new Error('Subagent session requires an agentId.')
+
+ const projectDir = input.projectDir?.trim() || null
+ const runtimeConfig = await this.dependencies.assignmentPolicy.resolveSubagentAssignment({
+ agentId,
+ targetAgentId: input.targetAgentId,
+ projectDir,
+ providerId: input.providerId,
+ modelId: input.modelId,
+ generationSettings: input.generationSettings,
+ disabledAgentTools: input.disabledAgentTools,
+ activeSkills: input.activeSkills
+ })
+ const subagentMeta: DeepChatSubagentMeta = {
+ slotId,
+ displayName,
+ targetAgentId: runtimeConfig.targetAgentId || null
+ }
+ let lastError: unknown = null
+
+ for (let attempt = 1; attempt <= SUBAGENT_SESSION_INIT_MAX_ATTEMPTS; attempt += 1) {
+ const sessionId = this.dependencies.sessions.create(
+ runtimeConfig.agentId,
+ displayName,
+ projectDir,
+ {
+ isDraft: false,
+ disabledAgentTools: runtimeConfig.disabledAgentTools,
+ subagentEnabled: false,
+ sessionKind: 'subagent',
+ parentSessionId,
+ subagentMeta
+ }
+ )
+
+ try {
+ await this.initializeSessionRuntime(sessionId, {
+ agentId: runtimeConfig.agentId,
+ providerId: runtimeConfig.providerId,
+ modelId: runtimeConfig.modelId,
+ projectDir,
+ permissionMode: input.permissionMode,
+ generationSettings: runtimeConfig.generationSettings
+ })
+ if (runtimeConfig.activeSkills.length > 0) {
+ await this.dependencies.skills.setActiveSkills(sessionId, runtimeConfig.activeSkills)
+ }
+ if (!this.dependencies.sessions.get(sessionId)) {
+ throw new Error(`Subagent session not found after creation: ${sessionId}`)
+ }
+
+ const session = await this.dependencies.projection.materializeRequired(sessionId)
+ this.dependencies.projection.notify({ sessionIds: [session.id], reason: 'created' })
+ return session
+ } catch (error) {
+ lastError = error
+ await this.cleanupFailedSessionInitialization(sessionId, runtimeConfig.providerId)
+ if (attempt >= SUBAGENT_SESSION_INIT_MAX_ATTEMPTS) throw error
+
+ console.warn(
+ `[SessionLifecycleCoordinator] Retrying subagent session initialization (${attempt}/${SUBAGENT_SESSION_INIT_MAX_ATTEMPTS - 1} retry used) for agent=${runtimeConfig.agentId} slot=${slotId}:`,
+ error
+ )
+ }
+ }
+
+ throw lastError instanceof Error
+ ? lastError
+ : new Error(`Failed to create subagent session for slot ${slotId}.`)
+ }
+
+ async ensureAcpDraftSession(input: {
+ agentId: string
+ projectDir: string
+ permissionMode?: PermissionMode
+ }): Promise {
+ const agentId = input.agentId?.trim()
+ if (!agentId) throw new Error('ACP draft session requires an agentId.')
+
+ const projectDir = input.projectDir?.trim()
+ if (!projectDir) throw new Error('ACP draft session requires a non-empty projectDir.')
+
+ const { agentId: canonicalAgentId, permissionMode } =
+ this.dependencies.assignmentPolicy.resolveAcpDraftAssignment(agentId, input.permissionMode)
+ let record = await this.findReusableDraftSession(canonicalAgentId, projectDir)
+ let createdDraftSession = false
+ if (!record) {
+ const sessionId = this.dependencies.sessions.create(
+ canonicalAgentId,
+ 'New Chat',
+ projectDir,
+ { isDraft: true, subagentEnabled: false }
+ )
+ try {
+ await this.ensureSessionRuntimeInitialized(sessionId, {
+ agentId: canonicalAgentId,
+ providerId: 'acp',
+ modelId: canonicalAgentId,
+ projectDir,
+ permissionMode
+ })
+ } catch (error) {
+ await this.cleanupFailedSessionInitialization(sessionId, 'acp')
+ throw error
+ }
+ record = this.dependencies.sessions.get(sessionId)
+ if (!record) throw new Error(`Failed to read created ACP draft session: ${sessionId}`)
+ createdDraftSession = true
+ } else {
+ await this.ensureSessionRuntimeInitialized(record.id, {
+ agentId: canonicalAgentId,
+ providerId: 'acp',
+ modelId: canonicalAgentId,
+ projectDir,
+ permissionMode
+ })
+ }
+
+ await this.dependencies.workdir.prepareDirectAcpSession(record.id)
+ this.dependencies.projection.notify({
+ sessionIds: [record.id],
+ reason: createdDraftSession ? 'created' : 'updated'
+ })
+ const state = await this.dependencies.runtime
+ .resolveSession(toAppSessionId(record.id))
+ .snapshot()
+ return {
+ ...record,
+ status: state?.status ?? 'idle',
+ providerId: state?.providerId ?? 'acp',
+ modelId: state?.modelId ?? canonicalAgentId
+ }
+ }
+
+ async forkSession(
+ sourceSessionId: string,
+ targetMessageId: string,
+ newTitle?: string
+ ): Promise {
+ const sourceSession = this.dependencies.sessions.get(sourceSessionId)
+ if (!sourceSession) throw new Error(`Session not found: ${sourceSessionId}`)
+
+ const sourceRuntime = this.dependencies.runtime.resolveSession(toAppSessionId(sourceSessionId))
+ const sourceState = await sourceRuntime.snapshot()
+ if (!sourceState) throw new Error(`Session state not found: ${sourceSessionId}`)
+ const generationSettings = await sourceRuntime.getGenerationSettings()
+ const title = this.buildForkTitle(sourceSession.title, newTitle)
+ const targetSessionId = this.dependencies.sessions.create(
+ sourceSession.agentId,
+ title,
+ sourceSession.projectDir ?? null,
+ { isDraft: false }
+ )
+
+ try {
+ await this.initializeSessionRuntime(targetSessionId, {
+ agentId: sourceSession.agentId,
+ providerId: sourceState.providerId,
+ modelId: sourceState.modelId,
+ projectDir: sourceSession.projectDir ?? null,
+ permissionMode: sourceState.permissionMode,
+ generationSettings: generationSettings ?? undefined
+ })
+ await this.dependencies.transcript.forkSessionFromMessage(
+ sourceSessionId,
+ targetSessionId,
+ targetMessageId
+ )
+ } catch (error) {
+ try {
+ await this.dependencies.runtime.resolveSession(targetSessionId).close()
+ } catch (cleanupError) {
+ console.warn(
+ `[SessionLifecycleCoordinator] Failed to cleanup forked session runtime ${targetSessionId}:`,
+ cleanupError
+ )
+ }
+ this.dependencies.sessions.delete(targetSessionId)
+ throw error
+ }
+
+ this.dependencies.projection.notify({ sessionIds: [targetSessionId], reason: 'created' })
+ const record = this.dependencies.sessions.get(targetSessionId)
+ if (!record) throw new Error(`Forked session not found: ${targetSessionId}`)
+ const targetState = await this.dependencies.runtime.resolveSession(targetSessionId).snapshot()
+ return {
+ ...record,
+ status: targetState?.status ?? 'idle',
+ providerId: targetState?.providerId ?? sourceState.providerId,
+ modelId: targetState?.modelId ?? sourceState.modelId
+ }
+ }
+
+ async deleteSession(sessionId: string): Promise {
+ const deletedSessionIds = await this.dependencies.deletion.deleteSessionTree(sessionId)
+ this.dependencies.projection.notify({ sessionIds: deletedSessionIds, reason: 'deleted' })
+ }
+
+ private async findReusableDraftSession(
+ agentId: string,
+ projectDir: string
+ ): Promise {
+ for (const session of this.dependencies.sessions.list({ agentId, projectDir })) {
+ if (!session.isDraft) continue
+ if (!(await this.hasSessionMessages(session.id))) return session
+ }
+ return null
+ }
+
+ private async hasSessionMessages(sessionId: string): Promise {
+ try {
+ return await this.dependencies.transcript.hasMessages(sessionId)
+ } catch (error) {
+ console.warn(
+ `[SessionLifecycleCoordinator] Failed to inspect messages for session=${sessionId}:`,
+ error
+ )
+ return true
+ }
+ }
+
+ private async ensureSessionRuntimeInitialized(
+ sessionId: string,
+ config: SessionLifecycleRuntimeConfig & { projectDir: string }
+ ): Promise {
+ const runtime = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId))
+ if (!(await runtime.isInitialized())) {
+ await this.initializeSessionRuntime(sessionId, config)
+ return
+ }
+ const state = await runtime.snapshot()
+ if (!state) throw new Error(`Session ${sessionId} not found`)
+ if (state.permissionMode && state.permissionMode !== config.permissionMode) {
+ await runtime.setPermissionMode(config.permissionMode)
+ }
+ await this.dependencies.workdir.syncAcpSessionWorkdir(
+ config.providerId,
+ sessionId,
+ config.agentId ?? config.modelId,
+ config.projectDir
+ )
+ }
+
+ private async initializeSessionRuntime(
+ sessionId: string,
+ config: SessionLifecycleRuntimeConfig
+ ): Promise {
+ await this.dependencies.runtime.resolveSession(toAppSessionId(sessionId)).initialize(config)
+ await this.dependencies.workdir.syncAcpSessionWorkdir(
+ config.providerId,
+ sessionId,
+ config.agentId ?? config.modelId,
+ config.projectDir ?? null
+ )
+ }
+
+ private async cleanupFailedSessionInitialization(
+ sessionId: string,
+ providerId?: string
+ ): Promise {
+ try {
+ const runtime = this.dependencies.runtime.resolveSession(toAppSessionId(sessionId))
+ if (providerId === 'acp' && runtime.kind !== 'acp') {
+ try {
+ await this.dependencies.workdir.clearCompatibilityAcpSession(sessionId)
+ } catch (error) {
+ console.warn(
+ `[SessionLifecycleCoordinator] Failed to clear ACP session after initialization error ${sessionId}:`,
+ error
+ )
+ }
+ }
+
+ await runtime.close()
+ } catch (cleanupError) {
+ console.warn(
+ `[SessionLifecycleCoordinator] Failed to cleanup session runtime after initialization error ${sessionId}:`,
+ cleanupError
+ )
+ } finally {
+ this.dependencies.sessions.delete(sessionId)
+ }
+ }
+
+ private buildForkTitle(sourceTitle: string, customTitle?: string): string {
+ const normalizedCustom = customTitle?.trim()
+ if (normalizedCustom) return normalizedCustom
+ const base = sourceTitle?.trim() || 'New Chat'
+ return base.length >= 60 ? base.slice(0, 60).trim() : `${base} - Fork`
+ }
+}
diff --git a/src/main/presenter/sessionApplication/lifecycleDeletionTransaction.ts b/src/main/presenter/sessionApplication/lifecycleDeletionTransaction.ts
new file mode 100644
index 0000000000..1e3a068203
--- /dev/null
+++ b/src/main/presenter/sessionApplication/lifecycleDeletionTransaction.ts
@@ -0,0 +1,59 @@
+import { toAppSessionId } from '@/agent/shared/agentSessionIds'
+import type {
+ SessionDeletionPermissionPort,
+ SessionDeletionProjectionPort,
+ SessionDeletionRuntimePort,
+ SessionDeletionSkillPort,
+ SessionDeletionStatePort,
+ SessionDeletionStorePort,
+ SessionLifecycleDeletionPort
+} from './ports'
+
+export interface SessionDeletionTransactionDependencies {
+ sessions: SessionDeletionStorePort
+ runtime: SessionDeletionRuntimePort
+ state: SessionDeletionStatePort
+ permissions: SessionDeletionPermissionPort
+ skills: SessionDeletionSkillPort
+ projection: SessionDeletionProjectionPort
+}
+
+export class SessionDeletionTransaction implements SessionLifecycleDeletionPort {
+ constructor(private readonly dependencies: SessionDeletionTransactionDependencies) {}
+
+ async deleteSessionTree(sessionId: string): Promise {
+ const session = this.dependencies.sessions.get(sessionId)
+ if (!session) return []
+
+ const deletedSessionIds: string[] = []
+ if (session.sessionKind === 'regular') {
+ const children = this.dependencies.sessions.list({
+ includeSubagents: true,
+ parentSessionId: sessionId
+ })
+ for (const child of children) {
+ deletedSessionIds.push(...(await this.deleteSessionTree(child.id)))
+ }
+ }
+
+ let backendCleanupError: unknown
+ try {
+ await this.dependencies.runtime.cleanupSessionBackends(toAppSessionId(sessionId))
+ } catch (error) {
+ backendCleanupError = error
+ }
+ try {
+ await this.dependencies.state.destroySession(sessionId)
+ } catch (error) {
+ if (!backendCleanupError) throw error
+ }
+ if (backendCleanupError) throw backendCleanupError
+
+ this.dependencies.permissions.clearSessionPermissions(sessionId)
+ await this.dependencies.skills.clearNewAgentSessionSkills(sessionId)
+ this.dependencies.sessions.delete(sessionId)
+ this.dependencies.projection.forgetStatus([sessionId])
+ deletedSessionIds.push(sessionId)
+ return deletedSessionIds
+ }
+}
diff --git a/src/main/presenter/sessionApplication/ports.ts b/src/main/presenter/sessionApplication/ports.ts
new file mode 100644
index 0000000000..090e48d84c
--- /dev/null
+++ b/src/main/presenter/sessionApplication/ports.ts
@@ -0,0 +1,563 @@
+import type { AppSessionId } from '@/agent/shared/agentSessionIds'
+import type {
+ AgentSessionStatePort,
+ AgentTapePort,
+ AgentTranscriptMutationPort,
+ AgentTranscriptReadPort
+} from '@/agent/shared/agentSharedData'
+import type { AgentSessionSendInput } from '@/agent/shared/agentSessionHandle'
+import type {
+ AgentPendingInputFacet,
+ AgentToolInteractionFacet
+} from '@/agent/manager/sessionHandles'
+import type {
+ ResolvedAgentSession,
+ ResolvedDeepChatTransferTarget,
+ ResolvedSubagentFacet,
+ ResolvedTransferSource
+} from '@/agent/manager/agentManager'
+import type {
+ AgentTransferImpact,
+ ChatMessageRecord,
+ CreateDetachedSessionInput,
+ CreateSessionInput,
+ DeepChatAgentConfig,
+ DeepChatSubagentMeta,
+ DeepChatSessionState,
+ MessageStartResult,
+ PendingSessionInputRecord,
+ PermissionMode,
+ SendMessageInput,
+ SessionCompactionState,
+ SessionGenerationSettings,
+ SessionKind,
+ SessionLightweightListResult,
+ SessionListItem,
+ SessionPageCursor,
+ SessionRecord,
+ SessionWithState,
+ SessionMetadata,
+ ToolInteractionResponse,
+ ToolInteractionResult
+} from '@shared/types/agent-interface'
+import type { AcpConfigState } from '@shared/presenter'
+import type { AcpAsLlmProviderSessionControlPort } from '../runtimePorts'
+import type { DeepChatMessageRow } from '../sqlitePresenter/tables/deepchatMessages'
+import type { DeepChatMessageSearchResultRow } from '../sqlitePresenter/tables/deepchatMessageSearchResults'
+import type { DeepChatMessageTraceRow } from '../sqlitePresenter/tables/deepchatMessageTraces'
+
+export interface SessionProjectionStorePort {
+ get(sessionId: string): SessionRecord | null
+ getMany(sessionIds: string[]): SessionRecord[]
+ list(filters?: SessionListFilters): SessionRecord[]
+ listPage(options?: SessionLightweightOptions): {
+ records: SessionRecord[]
+ nextCursor: SessionPageCursor | null
+ hasMore: boolean
+ }
+ update(sessionId: string, fields: Partial>): void
+ bindWindow(webContentsId: number, sessionId: AppSessionId): void
+ unbindWindow(webContentsId: number): void
+ getActiveSessionId(webContentsId: number): AppSessionId | null
+}
+
+export interface SessionProjectionRuntimePort {
+ getAgentKind(agentId: string): 'deepchat' | 'acp'
+ snapshot(
+ sessionId: string,
+ options?: { lightweight?: boolean }
+ ): Promise
+ waitForFirstTurnReady(sessionId: string, options: { timeoutMs: number }): Promise
+}
+
+export type SessionProjectionTranscriptPort = Pick<
+ AgentTranscriptReadPort,
+ 'getMessages' | 'listMessagesPage' | 'getMessageIds' | 'getMessage'
+>
+
+export type SessionProjectionTapePort = Pick<
+ AgentTapePort,
+ | 'getTapeInfo'
+ | 'searchTape'
+ | 'getTapeContext'
+ | 'listTapeAnchors'
+ | 'handoffTape'
+ | 'listMessageViewManifests'
+ | 'exportMessageTapeReplaySlice'
+>
+
+export interface SessionProjectionMessageLookupPort {
+ get(messageId: string): Pick | null | undefined
+}
+
+export interface SessionProjectionSearchResultStorePort {
+ listByMessageId(messageId: string): DeepChatMessageSearchResultRow[]
+}
+
+export interface SessionProjectionTraceStorePort {
+ listByMessageId(messageId: string): DeepChatMessageTraceRow[]
+ countByMessageId(messageId: string): number
+}
+
+export interface SessionProjectionTitlePort {
+ summaryTitles(
+ messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>,
+ providerId: string,
+ modelId: string
+ ): Promise
+}
+
+export interface SessionProjectionAgentConfigPort {
+ getAssistantModel(
+ agentId: string
+ ): Promise<{ providerId?: string | null; modelId?: string | null } | null>
+}
+
+export type SessionProjectionEventReason =
+ | 'created'
+ | 'updated'
+ | 'deleted'
+ | 'list-refreshed'
+ | 'activated'
+ | 'deactivated'
+
+export interface SessionProjectionEventPort {
+ publish(payload: {
+ sessionIds: string[]
+ reason: SessionProjectionEventReason
+ activeSessionId?: string | null
+ webContentsId?: number
+ }): void
+}
+
+export interface SessionProjectionUiPort {
+ refreshSessionUi(): void
+}
+
+export interface SessionListFilters {
+ agentId?: string
+ projectDir?: string
+ includeSubagents?: boolean
+ parentSessionId?: string
+}
+
+export interface SessionLightweightOptions {
+ limit?: number
+ cursor?: SessionPageCursor | null
+ includeSubagents?: boolean
+ agentId?: string
+ prioritizeSessionId?: string
+}
+
+export interface SessionProjectionUpdate {
+ sessionIds?: string[]
+ reason?: 'created' | 'updated' | 'deleted' | 'list-refreshed'
+ activeSessionId?: string | null
+ webContentsId?: number
+}
+
+export interface TitleGenerationInput {
+ sessionId: string
+ initialTitle: string
+ fallbackProviderId: string
+ fallbackModelId: string
+}
+
+export interface SessionProjectionReadPort {
+ getSession(sessionId: string): Promise
+ listSessions(filters?: SessionListFilters): Promise
+ listLightweight(options?: SessionLightweightOptions): Promise
+ getLightweightByIds(sessionIds: string[]): Promise
+}
+
+export interface SessionWindowProjectionPort {
+ activate(webContentsId: number, sessionId: string): Promise
+ deactivate(webContentsId: number): Promise
+ getActive(webContentsId: number): Promise
+ getActiveId(webContentsId: number): string | null
+}
+
+export interface SessionProjectionMutationPort {
+ bindWindow(webContentsId: number, sessionId: string): void
+ materialize(sessionId: string): Promise
+ notify(input?: SessionProjectionUpdate): void
+ forgetStatus(sessionIds: string[]): void
+ scheduleTitleGeneration(input: TitleGenerationInput): void
+}
+
+export interface SessionTurnStorePort {
+ get(sessionId: string): SessionRecord | null
+ update(sessionId: string, fields: Partial>): void
+}
+
+interface SessionTurnRuntimeBase {
+ readonly pending: AgentPendingInputFacet
+ readonly toolInteractions: AgentToolInteractionFacet
+ send(input: AgentSessionSendInput): Promise
+ cancel(): Promise
+ snapshot(): Promise
+}
+
+export type SessionTurnRuntimeSession =
+ | (SessionTurnRuntimeBase & {
+ readonly kind: 'deepchat'
+ readonly compaction: {
+ getState(): Promise
+ compact(): Promise<{ compacted: boolean; state: SessionCompactionState }>
+ }
+ })
+ | (SessionTurnRuntimeBase & { readonly kind: 'acp' })
+
+export interface SessionTurnRuntimePort {
+ resolveSession(sessionId: AppSessionId): SessionTurnRuntimeSession
+}
+
+export type SessionTurnTranscriptPort = Pick &
+ Pick<
+ AgentTranscriptMutationPort,
+ 'clearMessages' | 'prepareRetryMessage' | 'deleteMessage' | 'editUserMessage'
+ >
+
+export type SessionTurnProjectionPort = Pick<
+ SessionProjectionMutationPort,
+ 'notify' | 'scheduleTitleGeneration'
+>
+
+export interface SessionInitialTurnInput {
+ sessionId: string
+ content: SendMessageInput
+ projectDir: string | null
+ initialTitle: string
+ fallbackProviderId: string
+ fallbackModelId: string
+}
+
+export interface SessionInitialTurnPort {
+ startInitialTurn(input: SessionInitialTurnInput): void
+}
+
+export interface SessionTurnPort {
+ sendMessage(
+ sessionId: string,
+ content: string | SendMessageInput,
+ options?: { maxProviderRounds?: number }
+ ): Promise
+ steerActiveTurn(sessionId: string, content: string | SendMessageInput): Promise
+ listPendingInputs(sessionId: string): Promise
+ queuePendingInput(
+ sessionId: string,
+ content: string | SendMessageInput
+ ): Promise
+ updateQueuedInput(
+ sessionId: string,
+ itemId: string,
+ content: string | SendMessageInput
+ ): Promise
+ moveQueuedInput(
+ sessionId: string,
+ itemId: string,
+ toIndex: number
+ ): Promise
+ convertPendingInputToSteer(sessionId: string, itemId: string): Promise
+ steerPendingInput(sessionId: string, itemId: string): Promise
+ deletePendingInput(sessionId: string, itemId: string): Promise
+ retryMessage(sessionId: string, messageId: string): Promise
+ deleteMessage(sessionId: string, messageId: string): Promise
+ editUserMessage(sessionId: string, messageId: string, text: string): Promise
+ getSessionCompactionState(sessionId: string): Promise
+ compactSession(sessionId: string): Promise<{ compacted: boolean; state: SessionCompactionState }>
+ clearSessionMessages(sessionId: string): Promise
+ cancelGeneration(sessionId: string): Promise
+ respondToolInteraction(
+ sessionId: string,
+ messageId: string,
+ toolCallId: string,
+ response: ToolInteractionResponse
+ ): Promise
+}
+
+export interface SessionAssignmentCatalogPort {
+ resolveAgent(agentId: string): { id: string; kind: 'deepchat' | 'acp' }
+}
+
+export interface SessionAssignmentConfigPort {
+ getDefaultModel(): { providerId: string; modelId: string } | null | undefined
+ getDefaultProjectPath(): string | null
+ resolveDeepChatAgentConfig(agentId: string): Promise
+}
+
+export interface CreateAssignmentInput {
+ agentId: string
+ providerId?: string
+ modelId?: string
+ projectDir?: string | null
+ permissionMode?: PermissionMode
+ generationSettings?: Partial
+ disabledAgentTools?: string[]
+ subagentEnabled?: boolean
+ preserveExplicitNullProjectDir: boolean
+}
+
+export interface ResolvedSessionAssignment {
+ agentId: string
+ agentType: 'deepchat' | 'acp'
+ providerId: string
+ modelId: string
+ projectDir: string | null
+ permissionMode: PermissionMode
+ generationSettings?: Partial
+ disabledAgentTools: string[]
+ subagentEnabled: boolean
+}
+
+export interface SubagentAssignmentInput {
+ agentId: string
+ targetAgentId?: string | null
+ projectDir: string | null
+ providerId: string
+ modelId: string
+ generationSettings?: Partial
+ disabledAgentTools?: string[]
+ activeSkills?: string[]
+}
+
+export interface ResolvedSubagentAssignment {
+ agentId: string
+ targetAgentId: string | null
+ providerId: string
+ modelId: string
+ generationSettings?: Partial
+ disabledAgentTools: string[]
+ activeSkills: string[]
+}
+
+export interface ResolvedTransferTarget {
+ agentId: string
+ providerId: string
+ modelId: string
+ projectDir: string | null
+ permissionMode: PermissionMode
+ generationSettings?: Partial
+ disabledAgentTools: string[]
+ subagentEnabled: boolean
+}
+
+export interface SessionAssignmentPolicyPort {
+ resolveCreateAssignment(input: CreateAssignmentInput): Promise
+ resolveAcpDraftAssignment(
+ agentId: string,
+ permissionMode?: PermissionMode
+ ): { agentId: string; permissionMode: PermissionMode }
+ resolveSubagentAssignment(input: SubagentAssignmentInput): Promise
+ resolveTransferTarget(
+ targetAgentId: string,
+ currentProjectDir: string | null
+ ): Promise
+ assertAcpSessionHasWorkdir(providerId: string, projectDir: string | null): void
+}
+
+export interface SessionAssignmentStorePort {
+ get(sessionId: string): SessionRecord | null
+ list(filters?: SessionListFilters): SessionRecord[]
+ update(
+ sessionId: string,
+ fields: Partial>
+ ): void
+ updateAgentId(sessionId: string, agentId: string): void
+ getDisabledAgentTools(sessionId: string): string[]
+ updateDisabledAgentTools(sessionId: string, disabledAgentTools: string[]): void
+}
+
+export interface SessionAssignmentRuntimePort {
+ getSessionAgentKind(sessionId: AppSessionId): 'deepchat' | 'acp'
+ resolveSession(sessionId: AppSessionId): ResolvedAgentSession
+ resolveTransferSource(sessionId: AppSessionId): ResolvedTransferSource
+ resolveDeepChatTransferTarget(agentId: string): ResolvedDeepChatTransferTarget
+ resolveSubagentFacet(sessionId: AppSessionId): ResolvedSubagentFacet
+}
+
+export interface SessionAssignmentEnvironmentPort {
+ syncPath(projectDir: string): void
+}
+
+export type SessionAssignmentProjectionPort = Pick<
+ SessionProjectionMutationPort,
+ 'materialize' | 'notify'
+>
+
+export interface SessionLifecycleDeletionPort {
+ deleteSessionTree(sessionId: string): Promise
+}
+
+export interface SessionAssignmentWorkdirPort {
+ assertAcpSessionHasWorkdir(providerId: string, projectDir: string | null): void
+ syncAcpSessionWorkdir(
+ providerId: string,
+ sessionId: string,
+ agentId: string,
+ projectDir?: string | null
+ ): Promise
+ prepareDirectAcpSession(sessionId: string): Promise
+ clearCompatibilityAcpSession(sessionId: string): Promise
+}
+
+export interface SessionLifecycleStorePort {
+ create(
+ agentId: string,
+ title: string,
+ projectDir: string | null,
+ options?: {
+ isDraft?: boolean
+ disabledAgentTools?: string[]
+ subagentEnabled?: boolean
+ sessionKind?: SessionKind
+ parentSessionId?: string | null
+ subagentMeta?: DeepChatSubagentMeta | null
+ metadata?: SessionMetadata | null
+ }
+ ): AppSessionId
+ get(sessionId: string): SessionRecord | null
+ list(filters?: SessionListFilters): SessionRecord[]
+ delete(sessionId: string): void
+}
+
+export interface SessionLifecycleRuntimeConfig {
+ agentId?: string
+ providerId: string
+ modelId: string
+ projectDir?: string | null
+ permissionMode: PermissionMode
+ generationSettings?: Partial
+}
+
+export interface SessionLifecycleRuntimeSession {
+ readonly kind: 'deepchat' | 'acp'
+ initialize(config: SessionLifecycleRuntimeConfig): Promise
+ isInitialized(): Promise
+ snapshot(): Promise
+ getGenerationSettings(): Promise
+ setPermissionMode(mode: PermissionMode): Promise
+ close(): Promise
+}
+
+export interface SessionLifecycleRuntimePort {
+ resolveSession(sessionId: AppSessionId): SessionLifecycleRuntimeSession
+}
+
+export type SessionLifecycleTranscriptPort = Pick &
+ Pick
+
+export interface SessionLifecycleSkillPort {
+ setActiveSkills(sessionId: string, activeSkills: string[]): Promise
+}
+
+export type SessionLifecycleProjectionPort = Pick<
+ SessionProjectionMutationPort,
+ 'bindWindow' | 'notify'
+> & {
+ materializeRequired(sessionId: string): Promise
+}
+
+export interface SessionLifecycleSubagentInput {
+ parentSessionId: string
+ agentId: string
+ slotId: string
+ displayName: string
+ targetAgentId?: string | null
+ projectDir?: string | null
+ providerId: string
+ modelId: string
+ permissionMode: PermissionMode
+ generationSettings?: Partial
+ disabledAgentTools?: string[]
+ activeSkills?: string[]
+}
+
+export interface SessionLifecyclePort {
+ createSession(input: CreateSessionInput, webContentsId: number): Promise
+ createDetachedSession(input: CreateDetachedSessionInput): Promise
+ createSubagentSession(input: SessionLifecycleSubagentInput): Promise
+ ensureAcpDraftSession(input: {
+ agentId: string
+ projectDir: string
+ permissionMode?: PermissionMode
+ }): Promise
+ forkSession(
+ sourceSessionId: string,
+ targetMessageId: string,
+ newTitle?: string
+ ): Promise
+ deleteSession(sessionId: string): Promise
+}
+
+export interface SessionAgentAssignmentPort {
+ mergeSubagentTape(
+ parentSessionId: string,
+ childSessionId: string,
+ meta?: Record
+ ): Promise
+ discardSubagentTape(
+ parentSessionId: string,
+ childSessionId: string,
+ meta?: Record
+ ): Promise
+ getAgentTransferImpact(agentId: string): Promise
+ moveAgentSessions(
+ fromAgentId: string,
+ toAgentId: string
+ ): Promise<{ movedSessionIds: string[]; deletedSessionIds: string[] }>
+ deleteAgentSessions(agentId: string): Promise
+ moveSessionToAgent(sessionId: string, toAgentId: string): Promise
+ getAcpSessionCommands(sessionId: string): Promise<
+ Array<{
+ name: string
+ description: string
+ input?: { hint: string } | null
+ }>
+ >
+ getAcpSessionConfigOptions(sessionId: string): Promise
+ setAcpSessionConfigOption(
+ sessionId: string,
+ configId: string,
+ value: string | boolean
+ ): Promise
+ getPermissionMode(sessionId: string): Promise
+ setPermissionMode(sessionId: string, mode: PermissionMode): Promise