refactor(agent-core-v2): clean up workspace-domain leftovers and docs

- Drop dead code: v2 mergeCallerMcpServers, the transitional
  ISessionContext.additionalDirs field, an unreachable guard
- Fix stale domain references in comments; correct test names
- Give the fs-watch refresh test a realistic wait budget under load
- Document the four-scope model and workspace domain in AGENTS.md,
  agent-core-v2 docs, and the agent-core-dev skill
This commit is contained in:
haozhe.yang 2026-07-29 20:23:33 +08:00
parent acf2059365
commit cf3e93591f
37 changed files with 119 additions and 175 deletions

View file

@ -14,7 +14,7 @@ v1 is a **VSCode-style singleton container**: services self-register with `regis
|---|---|---|
| Registration | `registerSingleton(IX, X, InstantiationType.Delayed)` | `registerScopedService(LifecycleScope.X, IX, X, ScopeActivation.OnDemand, 'domain')` |
| DI import | `from '../../di'` | `from '#/_base/di/scope'` / `'#/_base/di/instantiation'` / `'#/_base/di/lifecycle'` |
| Lifetime | implicit singleton-per-container | explicit `LifecycleScope` (App/Session/Agent) — see orient.md |
| Lifetime | implicit singleton-per-container | explicit `LifecycleScope` (App/Workspace/Session/Agent) — see orient.md |
| Domain granularity | coarse (`session`, `tool`, `loop`) | fine, split by scope + responsibility |
| Test import | `from '@moonshot-ai/agent-core/di/test'` | `from '#/_base/di/test'` |
| Resolve SUT in tests | `ix.createInstance(Impl)` (common) | `ix.get(IX)` by interface — see test.md |
@ -63,7 +63,7 @@ Worked example — v1 `ISessionService` (one class, ~600 lines) holds:
- this session's metadata → **per-session** unit → v2 `sessionMetaStore` (`ISessionMetaStore`, Session);
- this session's activity / status → **per-session** unit → v2 `sessionActivity`;
- this session's context projection → **per-session** unit → v2 `sessionContext`;
- child-agent lifecycle driven by a session → **per-session** unit → v2 `agentLifecycle`; create/close/archive/fork of the session itself → **global** unit → v2 `sessionLifecycle` (App).
- child-agent lifecycle driven by a session → **per-session** unit → v2 `agentLifecycle`; create/close/archive/fork of the session itself → **per-workspace** unit → v2 `workspaceHandler` (Workspace, one per live workspace handler).
A v1 class that maps cleanly to one v1 decorator often becomes **three to five** v2 Services. That is expected and correct — do not try to keep the v1 class shape.

View file

@ -20,6 +20,7 @@ A Service = a bundle of **state** + a set of **behaviors**, bound to a **lifetim
| Scope | State identity (keyed by) | Lifetime |
|---|---|---|
| `App` | none (single global instance) | the process |
| `Workspace` | `workspaceId` | one workspace handler (materialized once per workspace, never closed — dies with the process) |
| `Session` | `sessionId` | one session |
| `Agent` | `agentId` | one agent |
@ -33,6 +34,7 @@ A Service = a bundle of **state** + a set of **behaviors**, bound to a **lifetim
**Q2. What is the identity of that state?**
- one global instance → **`App`**
- one per workspace (shared by every session of that workspace) → **`Workspace`**
- one per session → **`Session`**
- one per agent → **`Agent`**
- a mix (a global registry *and* per-instance state) → **split it** (see §3).
@ -70,7 +72,7 @@ The standard split is "global registry / factory" + "per-instance":
| Tier | Role | Naming tends to |
|---|---|---|
| `App` | global registry / catalog / factory — knows "all of them" and how to create one | `XxxStore` / `XxxRegistry` / `XxxCatalog` |
| `Session` / `Agent` | one instance — only the state of "this one" | `XxxService` / `ISessionXxx` / `IAgentXxx` |
| `Workspace` / `Session` / `Agent` | one instance — only the state of "this one" | `XxxService` / `IWorkspaceXxx` / `ISessionXxx` / `IAgentXxx` |
Canonical splits in the codebase:
@ -189,8 +191,9 @@ domain: `<name>` (owning scope: <Scope>)
│ └─ (accessor) <ConsumerDomain> @<Scope><what they use me for>
├─ exposes (interfaces I provide, by scope)
│ ├─ App : <IXxxRegistry><role>
│ ├─ Session : <ISessionXxx><role>
│ └─ Agent : <IAgentXxx><role>
│ ├─ Workspace : <IWorkspaceXxx><role>
│ ├─ Session : <ISessionXxx><role>
│ └─ Agent : <IAgentXxx><role>
└─ depends (what I inject) tag = calling style
└─ <DepDomain> @<Scope> direct/event/hook — <what for>
```
@ -225,47 +228,54 @@ Read it as:
- `──holds──►` = the ancestor owns a handle to the child scope (it stores the key, not the service). DI allows this.
- `accessor.get(...)` = a **runtime borrow**, not a dependency edge. It must cross an `IScopeHandle`, run on demand, never be cached, and finish before the child scope is disposed.
Worked example — `sessionLifecycle`:
Worked example — `workspaceHandler`:
```text
domain: `sessionLifecycle` (owning scope: App)
domain: `workspaceHandler` (owning scope: Workspace)
├─ serves (who uses me)
│ ├─ (inject) — (none yet)
│ ├─ (inject) — (none)
│ └─ (accessor)
│ ├─ sessionLegacy @App(edge) — v1-compatible create/fork/archive/…
│ └─ gateway / rpc @App(edge) — native v2 session lifecycle actions
├─ exposes (interfaces I provide, by scope)
│ ├─ App : ISessionLifecycleService — owns the live session scope tree
│ ├─ Workspace : IWorkspaceHandlerService — owns this workspace's live session scope tree
│ ├─ Session : — — (per-session state lives in sessionMetadata / agentLifecycle / …)
│ └─ Agent : — — (per-agent state lives in agentLifecycle)
└─ depends (what I inject)
├─ bootstrap @App direct — addresses session storage
├─ hostEnvironment @App direct — gates scope creation on the probe
├─ sessionIndex @App direct — persisted read model for cold resumes
├─ storage @App direct — atomic docs + append logs
├─ workspace @App direct — resolves a session's workspace
└─ event @App direct — broadcasts session-level facts (e.g. archived)
├─ workspaceContext @Workspace seed — handler identity + persistence scope
├─ bootstrap @App direct — addresses session storage
├─ hostEnvironment @App direct — gates scope creation on the probe
├─ sessionIndex @App direct — persisted read model for cold resumes
├─ storage @App direct — atomic docs + append logs
├─ workspaceDirs / workspaceSkillCatalog / workspaceMcp / …
@Workspace direct — the handler's shared resource services
└─ event @App direct — broadcasts session-level facts (e.g. archived)
```
Cross-scope borrow for `sessionLifecycle`:
Cross-scope borrow for `workspaceHandler`:
```text
App scope
SessionLifecycleService ──holds──┐
GatewayService ───────────holds──┼──► IScopeHandle(sessionId)
│ accessor.get(ISessionMetadata) …
│ └── resolve runs inside the Session scope
Session scope (sessionId)
sessionMetadata / agentLifecycle / … ← per-session services live here
WorkspaceLifecycleService ──holds──► IScopeHandle(workspaceId) (one per live handler)
│ accessor.get(IWorkspaceHandlerService)
│ └── resolve runs inside the Workspace scope
Workspace scope (workspaceId)
WorkspaceHandlerService ──holds──► IScopeHandle(sessionId)
│ accessor.get(ISessionMetadata) …
│ └── resolve runs inside the Session scope
Session scope (sessionId)
sessionMetadata / agentLifecycle / … ← per-session services live here
```
How the three lenses shaped it:
- **Scope (§2)** → the live registry of session scopes is process-wide, so it is App-scoped; per-session data stays in Session-scoped services, reached through the handle's `accessor`.
- **Dependency direction (§5)**`sessionLifecycle` is consumed by the edge via `accessor` borrows; it never imports the edge. Every downward arrow lands on a peer or a more foundational Service.
- **Extension points (§4)** → new per-session behavior plugs into the Session-scoped services (`sessionMetadata`, `agentLifecycle`, `sessionActivity`); new transports stay at the edge. Neither edits `sessionLifecycle`.
- **Scope (§2)** → the live registry of one workspace's session scopes is per-handler, so it is Workspace-scoped; the process-wide handler registry lives in the App-scoped `workspaceLifecycle`; per-session data stays in Session-scoped services, reached through the handle's `accessor`.
- **Dependency direction (§5)**`workspaceHandler` is consumed by the edge via `accessor` borrows; it never imports the edge. Every downward arrow lands on a peer or a more foundational Service.
- **Extension points (§4)** → new per-session behavior plugs into the Session-scoped services (`sessionMetadata`, `agentLifecycle`, `sessionActivity`); new transports stay at the edge. Neither edits `workspaceHandler`.
For a multi-scope split, the `exposes` block fills more than one scope — see the `records` pattern in §3.

View file

@ -82,7 +82,7 @@ The `session` domain owns only Session-level identity, metadata, lifecycle comma
|---|---|---|
| `sessionId`, `workspaceId`, `sessionDir`, `metaScope` | `sessionContext` | Seeded facts; no IO |
| `SessionMeta` | `sessionMetadata` | Durable atomic document; entity-like |
| Open session scope registry | `sessionLifecycle` | App-scope live handles; not the persisted entity table |
| Open session scope registry | `workspaceHandler` | Workspace-scope live handles, one registry per workspace handler (the process-wide handler registry is `workspaceLifecycle`); not the persisted entity table |
| Session commands such as `archive()` | `session` | Orchestrates metadata, agent teardown, and events |
| Persisted session list / get / count | `sessionIndex` | Backend-neutral read model |
| Running / idle / awaiting status | `sessionActivity` | Derived from interactions and active turns; owns no state |

View file

@ -6,10 +6,11 @@ The transport (`/api/v2` over HTTP + WS) lives in the **edge** layer (`gateway`/
## 1. The edge model
Three scopes, three URL shapes, one dispatcher:
Four scopes, four URL shapes, one dispatcher:
```text
GET|POST /api/v2/:sa Core
GET|POST /api/v2/workspace/:workspace_id/:sa Workspace
GET|POST /api/v2/session/:session_id/:sa Session
GET|POST /api/v2/session/:session_id/agent/:agent_id/:sa Agent
```
@ -26,9 +27,10 @@ GET|POST /api/v2/session/:session_id/agent/:agent_id/:sa Agent
```ts
// actionMap — the allowlist; hides internal domain names.
const actionMap = {
core: { 'sessions:list': { service: ISessionIndex, method: 'list' }, ... },
session: { 'session:read': { service: ISessionMetadata, method: 'read' }, ... },
agent: { 'profile:getModel': { service: IProfileService, method: 'getModel' }, ... },
core: { 'sessions:list': { service: ISessionIndex, method: 'list' }, ... },
workspace: { 'skills:list': { service: IWorkspaceSkillCatalog, method: 'list' }, ... },
session: { 'session:read': { service: ISessionMetadata, method: 'read' }, ... },
agent: { 'profile:getModel': { service: IProfileService, method: 'getModel' }, ... },
};
```
@ -83,14 +85,14 @@ Read = `GET`, write = `POST`. `sid` = `session_id`, `aid` = `agent_id`.
| `session` | `setArchived` | ISessionMetadata.setArchived | POST |
| `session` | `status` | ISessionActivity.status | GET |
| `session` | `isIdle` | ISessionActivity.isIdle | GET |
| `session` | `archive` | ISessionLifecycleService.archive | POST |
| `session` | `archive` | IWorkspaceHandlerService.archive | POST |
| `approvals` | `listPending` | IApprovalService.listPending | GET |
| `approvals` | `decide` | IApprovalService.decide | POST |
| `questions` | `listPending` | IQuestionService.listPending | GET |
| `questions` | `answer` | IQuestionService.answer | POST |
| `interactions` | `listPending` | IInteractionService.listPending | GET |
| `interactions` | `respond` | IInteractionService.respond | POST |
| `workspace` | `workDir` / `additionalDirs` / `resolve` | IWorkspaceContext.* | GET |
| `workspace` | `workDir` / `additionalDirs` / `resolve` | ISessionWorkspaceContext.* | GET |
### Agent (`/api/v2/session/:sid/agent/:aid/:resource:action`)
@ -126,7 +128,7 @@ These fail §2 and must be wrapped in a facade that takes ids and returns data:
| Service | Why not direct | Facade shape |
|---|---|---|
| ISessionLifecycleService | returns `IScopeHandle` | `sessions.create` / `fork` / `close` / `archive` → wire Session |
| IWorkspaceHandlerService | returns `IScopeHandle` | `sessions.create` / `fork` / `close` / `archive` → wire Session |
| IAgentPromptService / IAgentTurnService | returns `Turn` handle | `prompts.submit` / `steer` / `abort` / `undo` |
| ILLMRequester | `AsyncIterable` stream | stream over WS, not RPC |
| ISubagentHost | `SubagentHandle` | `subagents.spawn` / `resume` → info |

View file

@ -246,7 +246,7 @@ If A needs B while being created and B needs A while being created, the containe
### Why cycles are disallowed
- Scope layering makes normal dependencies a DAG (Agent → Session → App, resolving upward); a cycle is almost always a design smell.
- Scope layering makes normal dependencies a DAG (Agent → Session → Workspace → App, resolving upward); a cycle is almost always a design smell.
- "Making the cycle happen to work" turns construction order into an implicit contract — hard to debug.
v2's stance: **the dependency graph must be acyclic.**

View file

@ -75,7 +75,7 @@ So a Session-scoped service is not "L1" — e.g. `session` is Session-scoped but
- **Header only.** Comments live solely in the top-of-file `/** */` block — never beside functions, methods, or statements. The code is the source of truth for *how*; the header states *what the module exposes and the responsibility it owns*.
- **Identity line first.** Start with `` `<domain>` domain (Ln) — <one-line role>. `` Keep an existing `(cross-cutting)` label as-is. Write the role as a responsibility ("drives the turn lifecycle"), not a symbol list.
- **Scope is in the filename.** `session*.ts` = Session, `agent*.ts` = Agent, no prefix = App (see service-authoring.md). State the same scope in the header so the two never drift.
- **Scope is in the filename.** `workspace*.ts` = Workspace, `session*.ts` = Session, `agent*.ts` = Agent, no prefix = App (see service-authoring.md). State the same scope in the header so the two never drift.
- **Interface files** (`<name>.ts`) state the public contract + scope: which `IXxx` they define and what it is for.
- **Impl files** (`<name>Service.ts`) add collaborators + scope: list every imported cross-domain collaborator as a role ("persists records through `records`"); read scope from `registerScopedService(LifecycleScope.X, …)`.
- **Contribution files** (`<targetDomain>.ts` / `<what>.contrib.ts`) state what they register into the target domain (e.g. "registers the `log` config section into `config`").

View file

@ -17,7 +17,7 @@ One folder per domain, **camelCase**: `session/`, `sessionActivity/`, `contextMe
```
- **Strictly one service per file.** An interface file holds exactly one injectable interface and exactly one `createDecorator(...)`; an impl file holds exactly one service implementation class and exactly one `registerScopedService(...)`. No exceptions for "tightly-coupled" groups: even same-scope collaborators each get their own `<name>.ts` + `<name>Service.ts` pair.
- **Scope is in the filename.** `session*.ts` = Session, `agent*.ts` = Agent, no scope prefix = App (see [Naming](#naming)). The header comment restates the same scope.
- **Scope is in the filename.** `workspace*.ts` = Workspace, `session*.ts` = Session, `agent*.ts` = Agent, no scope prefix = App (see [Naming](#naming)). The header comment restates the same scope.
- A domain therefore has as many impl files as it has services (e.g. `logService.ts` for the App `ILogService`, `sessionLogService.ts` for the Session `ISessionLogService`). See [Multi-Service domains](#multi-service-domains).
The package entry `src/index.ts` imports and `export *`s every domain's leaf files precisely (one line per leaf), so importing the package still runs every `registerScopedService(...)` side effect — exactly as the old per-domain barrels did.
@ -28,12 +28,12 @@ The package entry `src/index.ts` imports and `export *`s every domain's leaf fil
| Artifact | Rule | Example |
|---|---|---|
| Interface | `I` + scope prefix + PascalCase domain + role suffix. Scope prefix: `Session` / `Agent` / none (= App). Role suffix is usually `Service`. | `ISessionLogService`, `IAgentLoopService`, `ILogService` (App) |
| Interface | `I` + scope prefix + PascalCase domain + role suffix. Scope prefix: `Workspace` / `Session` / `Agent` / none (= App). Role suffix is usually `Service`. | `IWorkspaceDirs`, `ISessionLogService`, `IAgentLoopService`, `ILogService` (App) |
| Class | the interface name minus the leading `I`, plus `Service` if it does not already end in `Service`; `implements` the interface | `SessionLogService implements ISessionLogService`, `AppendLogStoreService implements IAppendLogStore` |
| Decorator string | lowerCamelCase of the interface name minus the leading `I`; **globally unique and stable** (it surfaces in `CyclicDependencyError.path` and "no service registered" errors) | `createDecorator<ISessionLogService>('sessionLogService')` |
| Model / non-service types | PascalCase, no `I` prefix | `SessionMeta`, `LogEntry`, `ConfigSection` |
The scope prefix makes a service's lifetime readable from its name. App services carry **no** prefix (App is the default, longest-lived tier); Session and Agent services always carry `Session` / `Agent`. The prefix applies to the interface, the class, and therefore the file names.
The scope prefix makes a service's lifetime readable from its name. App services carry **no** prefix (App is the default, longest-lived tier); Workspace, Session and Agent services always carry `Workspace` / `Session` / `Agent`. The prefix applies to the interface, the class, and therefore the file names.
> Do **not** use the scope prefix to re-merge domains by lifetime. `IAgentEntityService`, `IAgentDataService`, and `ISessionEntityService` are still banned — the prefix marks lifetime, the rest of the name must still be the real owning domain (`IBackgroundTaskEntityService`, `ISessionMetadata`, `IPermissionRulesService`). See [domain-boundaries.md](domain-boundaries.md).

File diff suppressed because one or more lines are too long

View file

@ -2,6 +2,10 @@
> New agent engine built on the DI Scope architecture — work-in-progress port of `packages/agent-core`. Design: `plan/PLAN.md`. Porting status: `GAP_ANALYSIS.md`.
## Scopes
Four `LifecycleScope` tiers — `App` (0) / `Workspace` (1) / `Session` (2) / `Agent` (3) (`src/_base/di/scope.ts`). The `workspace/` domain owns the Workspace tier: the App-scope `workspaceLifecycle` holds the live handler registry (one handler per workspaceId, create-or-get + join, never closed), and each handler's `workspaceHandler` owns the session lifecycle (create/resume/fork/close) as its child scopes. Workspace-scope services (`workspaceSkillCatalog` / `workspaceAgentProfileCatalog` / `workspaceInstructions` / `workspaceMcp` / `workspaceDirs` / `workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit` / `workspaceToolPolicy`) hold the handler-shared resources — loaded once at handler materialization, then refreshed by fs watch — and sessions consume them through session-domain seed contracts with change events. Dependency red line: **Session/Agent never import the Workspace domain**; the App-level `ISessionLifecycleService` / `ISessionMcpService` / `ISessionFsService` are gone — compose `sessionIndex``workspaceLifecycle.handlerFor` → the handler instead.
## Examples
> The runnable examples have moved to the standalone `kimi-code-mini-bench` package at `../kimi-code-mini-bench`. They are wired to `agent-core-v2` through a pnpm `link:` dependency and run as a separate Vitest project.

View file

@ -139,15 +139,16 @@ const meta = accessor.get(ISessionMetadata); // 类型是 ISessionMetadata
> 你要做的:每个会话一份、或每个 agent 一份。参考 [`sessionMetadata`](../src/session/sessionMetadata/sessionMetadata.ts)、[`turn`](../src/turn/turn.ts)。
这一步引入:**`LifecycleScope` 层生命周期** 与 **父子 scope 的可见性**
这一步引入:**`LifecycleScope` 层生命周期** 与 **父子 scope 的可见性**
### 3.1 层,按寿命从长到短
### 3.1 层,按寿命从长到短
```ts
export enum LifecycleScope {
App = 0, // 进程级,全局一份
Session = 1, // 一次会话
Agent = 2, // 一个 agent
App = 0, // 进程级,全局一份
Workspace = 1, // 一个工作区 handler与 Session 一对多)
Session = 2, // 一次会话
Agent = 3, // 一个 agent
}
```
@ -171,15 +172,16 @@ Scope 是一棵树,`kind` 必须沿父子方向**严格递增**
```
App (0)
└── Session (1)
└── Agent (2)
└── Workspace (1)
└── Session (2)
└── Agent (3)
```
解析服务时,容器先看自己这一层,没有就**递归问父 scope**。所以一条铁律:
> **短寿命的服务可以注入长寿命的服务,反过来不行。**
- ✅ Agent 服务注入 Session / App 服务(往上找,找得到)。
- ✅ Agent 服务注入 Session / Workspace / App 服务(往上找,找得到)。
- ❌ App 服务注入 Session 服务App 创建时 Session 还不存在,且父不会往下找)。
这条规则由树的结构强制保证,不靠纪律维持。
@ -350,7 +352,7 @@ A 创建中要 BB 创建中又要 A——容器会抛 `CyclicDependencyError`
### 9.2 为什么不允许
- scope 分层让正常依赖天然是 DAGAgent → Session → App 向上找),一个环几乎总是设计味道。
- scope 分层让正常依赖天然是 DAGAgent → Session → Workspace → App 向上找),一个环几乎总是设计味道。
- 靠「让环刚好能跑」会把构造顺序变成隐式约定,难调试、难排错。
所以 v2 的立场是:**依赖图必须是无环的。**

View file

@ -65,7 +65,7 @@
- W3 Session 域借 main agent 的 wire 写todo/cronmain 缺失时**静默丢写**
`sessionTodoService.ts:99-100`),且要 `as never` 绕过类型。
- W4 fork 直接在 appendLogStore 层改写 wire log绕过全部写模型
`sessionLifecycleService.ts:303-337`)。
`workspaceHandlerService.ts` 的 `fork` / `copyAgentWire`)。
- W5 restore 期 append 在 wireRecord 层被静默吞掉(`wireRecordService.ts:81`
但 recordService 仍然 foldViews、仍然跑 facet——"进内存不进磁盘"完全隐式。
@ -98,7 +98,7 @@
onChange 处理器若 append 会无检测地重入。
- L3 restore 正确性依赖三重隐式契约DI 构造顺序 + hook 注册顺序 +
"resumer 先于 hooks"`doResume` 需手动预热 contextMemory
`sessionLifecycleService.ts:158-162`)。
`workspaceHandlerService.ts``doResume` / `materializeSession`)。
- L4 相位规则restoring / postRestoring / live在 append/signal/push/hook
四条通道上各不相同,没有一处集中定义。
@ -189,7 +189,7 @@
逻辑 seq 顺序,因此边缘 journal 的 seq 与核心逻辑 seq 单调一致。
- fork 保持现实现(复制 main 的 wire log接口上表达为
`stream.forkInto(target)`,实现仍走 appendLogStoreW4 的接口层收口:
唯一入口,不再散落在 sessionLifecycle 里手写)。
唯一入口,不再散落在 workspaceHandler 里手写)。
- App scope 一条逻辑流config/model catalog/session 生命周期),取代
`IEventService`V4——App 流本就无持久化,纯接口替换。
- **Topic = 流上的类型化过滤视角**,不是独立机制。订阅方用

View file

@ -37,11 +37,12 @@ Every principle below derives from two root questions:
**First principle: Scope = the identity + lifetime of the owned state.**
`App` / `Session` / `Agent` are three tiers of identity + lifetime:
`App` / `Workspace` / `Session` / `Agent` are four tiers of identity + lifetime:
| Scope | State identity (keyed by) | Lifetime |
|---|---|---|
| `App` | none (single global instance) | the process |
| `Workspace` | `workspaceId` | one workspace handler (materialized once per workspace, never closed — dies with the process) |
| `Session` | `sessionId` | one session |
| `Agent` | `agentId` | one agent |
@ -55,6 +56,7 @@ Every principle below derives from two root questions:
**Q2. What is the identity of that state?**
- one global instance → **`App`**
- one per workspace (shared by every session of that workspace) → **`Workspace`**
- one per session → **`Session`**
- one per agent → **`Agent`**
- a mix (a global registry *and* per-instance state) → **do not put it in one Service;
@ -108,7 +110,7 @@ job well.
| Tier | Role | Naming tends to |
|---|---|---|
| `App` | **global registry / catalog / factory** — knows "all of them" and how to create one | `XxxStore` / `XxxRegistry` / `XxxCatalog` |
| `Session` / `Agent` | **one instance** — only the state of "this one" | `XxxService` / `ISessionXxx` / `IAgentXxx` |
| `Workspace` / `Session` / `Agent` | **one instance** — only the state of "this one" | `XxxService` / `IWorkspaceXxx` / `ISessionXxx` / `IAgentXxx` |
This pattern recurs throughout the codebase and confirms the rule:

View file

@ -228,7 +228,7 @@ const DOMAIN_LAYER = new Map([
// `activityView` is the Agent-scope read model folding the agent's own event
// bus into the activity projection (`agent.activity.updated`); it owns no
// authoritative state (turn mechanics live in `loop`, admission/drain in
// `sessionLifecycle`, background bookkeeping in `agentLifecycle`).
// `workspaceHandler`, background bookkeeping in `agentLifecycle`).
['activityView', 4],
['context', 4],
['message', 4],

View file

@ -2,7 +2,7 @@
* File content metadata helpers binary detection, line counting, etag, and
* extension-based mime / language guessing.
*
* Shared by the fs edge domains (`sessionFs`) and the kap-server fs routes so
* Shared by the fs edge domains (`workspaceFs`) and the kap-server fs routes so
* every read-style surface classifies and labels file content the same way.
* Pure functions over bytes, text, and stat-like shapes; no io happens here.
* Binary detection samples the leading `FS_BINARY_SAMPLE_BYTES` of a file and

View file

@ -6,7 +6,7 @@ const ISO_8601_REGEX =
/**
* Wire-schema primitive for ISO 8601 datetime strings: validates the shape and
* normalizes to `Date#toISOString()` output. Shared by the edge DTO schemas
* (`sessionFs`, `file`, `terminal`, `auth`, ) that expose timestamps.
* (`workspaceFs`, `file`, `terminal`, `auth`, ) that expose timestamps.
*/
export const isoDateTimeSchema = z
.string()

View file

@ -21,18 +21,3 @@ export async function resolveSessionMcpConfig(
if (Object.keys(servers).length === 0) return undefined;
return { servers };
}
export function mergeCallerMcpServers(
base: SessionMcpConfig | undefined,
callerServers: Readonly<Record<string, McpServerConfig>> | undefined,
): SessionMcpConfig | undefined {
if (callerServers === undefined || Object.keys(callerServers).length === 0) {
return base;
}
return {
servers: {
...base?.servers,
...callerServers,
},
};
}

View file

@ -4,7 +4,7 @@
* Top-level boolean preference (`default_plan_mode` on disk, v1-compatible):
* when `true`, every freshly created session starts in plan mode. Resumed /
* forked sessions restore plan state from wire records and ignore this. Read by
* `sessionLifecycle` at session creation; runtime plan state lives on the wire
* `workspaceHandler` at session creation; runtime plan state lives on the wire
* `PlanModel`, not here.
*/

View file

@ -26,7 +26,7 @@
* `agent.status.updated` planMode slice are NOT part of `apply`: they run
* after `wire.dispatch` on the live path, and `wire.replay` rebuilds the
* Model silently from the persisted `plan_mode.*` / `plan.revision` records
* (seeded by `sessionLifecycle`). The legacy `toReplay: plan_updated`
* (seeded by `workspaceHandler`). The legacy `toReplay: plan_updated`
* projection is dropped (inert nothing reads it). `plan.revision` carries
* a `toEvent` so the live transcript projector can map it onto a marker plus
* the plan badge; replay never emits it. Consumed by the Agent-scope

View file

@ -2,8 +2,9 @@
* `gateway` domain (L7) REST/WS gateways.
*
* Defines the public contracts of the gateway layer: the `IRestGateway` /
* `IWSGateway` entry points. Session scope creation is owned by
* `sessionLifecycle`; the gateway resolves sessions through it.
* `IWSGateway` entry points. Session scope creation is owned by the workspace
* handler (`workspaceHandler`); the gateway resolves sessions through the live
* handler registry (`workspaceLifecycle`).
* App-scoped shared across the application.
*/

View file

@ -3,8 +3,8 @@
*
* Defines the `IHostFolderBrowser` used by the program side (TUI / server) to
* let the user browse the real local filesystem when choosing a workspace
* folder. Distinct from the Session-side `sessionFs`, which is sandboxed and may
* be remote. App-scoped.
* folder. Distinct from the Workspace-side `workspaceFs`, which is sandboxed and
* may be remote. App-scoped.
*
* The wire shapes (`FsBrowseResponse` / `FsHomeResponse`) are defined here as
* zod schemas so the `/api/v1` and `/api/v2` transports share one contract.

View file

@ -5,7 +5,7 @@
* query facade over the set of persisted sessions (open or closed). It
* enumerates sessions and derives session identity (`workspaceId`), returning
* data (`SessionSummary`) or counts never filesystem paths or live handles.
* Writes (create / archive) live in `sessionLifecycle` / `session`; the index
* Writes (create / archive) live in `workspaceHandler` / `session`; the index
* is a read model. Backends are deployment-specific (local filesystem today;
* database / query store on a server).
*/

View file

@ -5,12 +5,12 @@
* metadata merge, and the cross-domain `agent_config` patch),
* `GET /sessions/{id}/status` (`status`), and `GET /sessions/{id}/goal`
* (`goal`) on top of the native v2 services
* (`ISessionLifecycleService`, `IAgentProfileService`, ).
* (`IWorkspaceHandlerService`, `IAgentProfileService`, ).
*
* The thin pass-through actions (`fork` / `compact` / `abort` / `archive`), the
* `:undo` action, and the `/sessions/{id}/children` endpoints are deliberately
* NOT wrapped here: the edge route calls the native services directly
* `ISessionLifecycleService.fork` / `archive` / `createChild`,
* `IWorkspaceHandlerService.fork` / `archive` / `createChild`,
* `IAgentFullCompactionService.begin`, `IAgentRPCService.cancel`,
* `IAgentPromptService.undo`, and `ISessionIndex.list({ childOf })` because
* none of them carries v1-only projection worth centralizing beyond what the

View file

@ -11,7 +11,8 @@
*
* Async initialization: probing (`ready`) discovers the shell path on
* Windows this may run `git.exe --exec-path`. The composition root
* (`sessionLifecycle`) `await`s `ready` before creating any Session scope, so
* (`workspaceLifecycle` / `workspaceHandler`) `await`s `ready` before creating
* any Session scope, so
* every Session/Agent-scope consumer reads the sync fields safely.
*
* App-scoped one shared instance for the whole process.

View file

@ -4,7 +4,7 @@
* Defines the `IHostFsWatchService`, a thin primitive over the host OS file
* watcher. It reports raw create/modify/delete events under an absolute path
* and knows nothing about sessions, connections, workspaces or wire frames.
* App-scoped one shared instance. Higher layers (e.g. `sessionFsWatch`)
* App-scoped one shared instance. Higher layers (e.g. `workspaceFsWatch`)
* subscribe, confine events to a workspace, debounce/coalesce and re-expose
* them as domain events.
*/

View file

@ -5,9 +5,10 @@
* registry (`get` / `list` / `remove`), and the lifecycle events plus the
* session-wide fan-outs only the live registry can reach
* (`broadcastPermissionMode`). Driving turns on an agent and the hook/event
* surface those runs announce lives in the `subagent` domain; session-level
* MCP lives in the `sessionMcp` domain. Session-scoped one instance per
* session.
* surface those runs announce lives in the `subagent` domain; the shared MCP
* connection manager lives in the Workspace-scope `workspaceMcp` domain and
* reaches agents through the seeded `ISessionMcpHandle`. Session-scoped one
* instance per session.
*
* Invariants:
* - The registry is flat: agents have no nesting. There is no parent/child or

View file

@ -12,7 +12,7 @@
* and returns an already-created main agent as-is so concurrent
* bootstrappers always receive the same, fully-bootstrapped handle (activity
* lane `idle`). Session services activated when their scope is created (cron,
* external hooks) are materialized by `sessionLifecycle.materializeSession`;
* external hooks) are materialized by `workspaceHandler.materializeSession`;
* the default permission posture is
* applied in `bindBootstrap`.
*

View file

@ -1,6 +1,6 @@
/**
* `session` domain error codes shared across the session layer
* (`sessionLifecycle` / `sessionLegacy` / `messageLegacy`).
* (`workspaceHandler` / `sessionLegacy` / `messageLegacy`).
*/
import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes';

View file

@ -3,16 +3,15 @@
*
* Defines the `ISessionContext` carrying the session's identity, storage
* addressing (`sessionId`, `workspaceId`, `sessionDir`, `metaScope`), the
* session's working directory (`cwd`) frozen at session creation the
* materialization-time snapshot of the handler's additional workspace
* directories (`additionalDirs`), and a `scope(subKey?)`
* session's working directory (`cwd`) frozen at session creation and a
* `scope(subKey?)`
* helper that returns the session's persistence scope (or a child under it,
* e.g. `scope('agents/main/cron')`). Seeded into the Session scope by
* `workspaceHandler` when the session is created.
*
* `cwd` is the default root the `process` runner spawns in and the seed the
* `workspaceContext` derives its read-only `workDir` / `additionalDirs` from.
* Pure facts no store, no IO. Session-scoped.
* `workspaceContext` derives its read-only `workDir` from. Pure facts no
* store, no IO. Session-scoped.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
@ -26,14 +25,6 @@ export interface ISessionContext {
readonly sessionDir: string;
readonly metaScope: string;
readonly cwd: string;
/**
* Extra directories beyond `cwd` the session may touch a snapshot of the
* handler-shared set (`workspaceDirs`: project-local `.kimi-code/local.toml`
* caller-provided dirs) taken at materialization. Live updates reach
* consumers through the `ISessionWorkspaceInfo` seed; this field stays the
* creation-time snapshot.
*/
readonly additionalDirs?: readonly string[];
scope(subKey?: string): string;
}
@ -50,7 +41,6 @@ export function makeSessionContext(input: {
readonly sessionDir: string;
readonly sessionScope: string;
readonly cwd: string;
readonly additionalDirs?: readonly string[];
readonly metaScope?: string;
}): ISessionContext {
const { sessionScope } = input;
@ -61,7 +51,6 @@ export function makeSessionContext(input: {
sessionDir: input.sessionDir,
metaScope: input.metaScope ?? sessionScope,
cwd: input.cwd,
additionalDirs: input.additionalDirs,
scope: (subKey?: string): string =>
subKey === undefined || subKey === '' ? sessionScope : `${sessionScope}/${subKey}`,
};

View file

@ -154,9 +154,11 @@ export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs {
* Watch the project root recursively, pruned to the `local.toml`
* candidate: watching the file directly never fires when its parent
* `.kimi-code` directory does not exist yet either.
*
* Runs only after `ready` resolves, so `reloadFromDisk` has already
* populated `projectRoot` / `configPath`.
*/
private watchLocalToml(): void {
if (this.configPath === '') return;
try {
const handle = this.fsWatch.watch(this.projectRoot, {
recursive: true,

View file

@ -226,8 +226,8 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan
// across all sessions of this workspace, §6.1) — the workspace dirs
// service owns the local.toml set and its watch; sessions read the
// combined view through the `ISessionWorkspaceInfo` seed below. Await
// the initial local.toml load first so the ctx snapshot and the seed
// both start from the assembled set.
// the initial local.toml load first so the seed starts from the
// assembled set.
await this.workspaceDirs.ready;
await this.workspaceDirs.mergeAdditionalDirs(opts.workDir, opts.additionalDirs ?? []);
const ctx: ISessionContext = {
@ -237,7 +237,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan
sessionDir,
metaScope,
cwd: opts.workDir,
additionalDirs: this.workspaceDirs.additionalDirs,
scope: (subKey?: string): string =>
subKey === undefined || subKey === '' ? sessionScope : `${sessionScope}/${subKey}`,
};

View file

@ -1,58 +0,0 @@
import { describe, expect, it } from 'vitest';
import { mergeCallerMcpServers, type SessionMcpConfig } from '#/agent/mcp/session-config';
import type { McpServerConfig } from '#/agent/mcp/config-schema';
const stdio = (command: string): McpServerConfig => ({
transport: 'stdio',
command,
});
const http = (url: string): McpServerConfig => ({
transport: 'http',
url,
});
describe('mergeCallerMcpServers', () => {
it('returns base unchanged when callerServers is undefined', () => {
const base: SessionMcpConfig = { servers: { fs: stdio('fs') } };
expect(mergeCallerMcpServers(base, undefined)).toBe(base);
});
it('returns base unchanged when callerServers is empty', () => {
const base: SessionMcpConfig = { servers: { fs: stdio('fs') } };
expect(mergeCallerMcpServers(base, {})).toBe(base);
});
it('returns undefined when both base and callerServers are absent', () => {
expect(mergeCallerMcpServers(undefined, undefined)).toBeUndefined();
expect(mergeCallerMcpServers(undefined, {})).toBeUndefined();
});
it('promotes a caller-only payload into a fresh SessionMcpConfig when base is undefined', () => {
const callerServers = { docs: http('https://mcp.example.com') };
expect(mergeCallerMcpServers(undefined, callerServers)).toEqual({
servers: { docs: http('https://mcp.example.com') },
});
});
it('layers caller on top of base with caller winning on key collision', () => {
const base: SessionMcpConfig = {
servers: {
shared: stdio('disk-version'),
diskOnly: stdio('disk-only'),
},
};
const callerServers = {
shared: stdio('caller-version'),
callerOnly: http('https://caller.example.com'),
};
expect(mergeCallerMcpServers(base, callerServers)).toEqual({
servers: {
shared: stdio('caller-version'),
diskOnly: stdio('disk-only'),
callerOnly: http('https://caller.example.com'),
},
});
});
});

View file

@ -371,7 +371,7 @@ function makeSession(
const emptyHandler: RunHandler = () => ({ stdout: '', exitCode: 0 });
describe('WorkspaceFsService.gitStatus', () => {
it('delegates to IGitService with the session cwd and a confined filter', async () => {
it('delegates to IWorkspaceGitService with the handler root and a confined filter', async () => {
const calls: Array<{ cwd: string; filter: ReadonlySet<string> | undefined }> = [];
const git: IGitService = {
_serviceBrand: undefined,
@ -413,7 +413,7 @@ describe('WorkspaceFsService.gitStatus', () => {
});
describe('WorkspaceFsService.diff', () => {
it('delegates to IGitService with confined rel and abs paths', async () => {
it('delegates to IWorkspaceGitService with confined rel and abs paths', async () => {
const calls: Array<{ cwd: string; rel: string; abs: string }> = [];
const git: IGitService = {
_serviceBrand: undefined,

View file

@ -416,7 +416,9 @@ describe('workspace resource sharing (handler chain)', () => {
() => {
expect(catalog.catalog.getSkill('watched-skill')?.description).toBe('from watch');
},
{ timeout: 10000, interval: 100 },
// Real FSEvents delivery + the 200 ms source debounce + a real disk
// rescan: under high parallel load the 10 s budget flakes, so allow 30 s.
{ timeout: 30000, interval: 100 },
);
}, 20000);
}, 60000);
});

View file

@ -1,7 +1,8 @@
/**
* The `fs:open` / `fs:open_in` / `fs:reveal` request schemas the only fs
* wire shapes the engine does not own (the `sessionFs` domain in agent-core-v2
* holds the rest). Also home of `fsOpenInAppIdSchema`, referenced by the
* wire shapes the engine does not own (the `workspaceFs` domain in
* agent-core-v2 holds the rest). Also home of `fsOpenInAppIdSchema`,
* referenced by the
* `/v1/meta` capabilities document.
*/

View file

@ -537,7 +537,7 @@ function sendMappedError(reply: Reply, req: { id: string }, err: unknown): void
case ErrorCodes.SESSION_NOT_FOUND:
reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId, err.stack));
return;
// hostFs errors that escaped the sessionFs layer keep their `os.fs.*`
// hostFs errors that escaped the workspaceFs layer keep their `os.fs.*`
// code; map them onto the closest v1 wire code (ENOTDIR collapses into
// path-not-found, matching `mapFsError`).
case ErrorCodes.OS_FS_NOT_FOUND:

View file

@ -26,7 +26,7 @@
* All file handling lives here in the transport layer on top of the os
* `IHostFileSystem` primitives the engine deliberately has no "unconfined
* read" domain Service. The mime / etag helpers are shared with the engine's
* `sessionFs` via `agent-core-v2/_base/utils/fileMeta` so both surfaces label
* `workspaceFs` via `agent-core-v2/_base/utils/fileMeta` so both surfaces label
* content the same way. `IHostFileSystem` failures arrive as coded `os.fs.*`
* errors and are mapped here:
*

View file

@ -3,7 +3,7 @@
* against a live engine scope and mirrors kap-server's dispatcher semantics
* (reflection call, non-function members are property reads, `main` agent
* auto-materialized via `ensureMainAgent`). Scope routing walks
* `ISessionLifecycleService` / `IAgentLifecycleService` exactly like the
* `IWorkspaceLifecycleService` / `IAgentLifecycleService` exactly like the
* server's `resolveScope`. Every argument, result, and event payload passes
* through `wireClone` (a JSON round-trip), so consumers observe
* byte-identical data no matter whether the call crossed a socket or stayed