Merge remote-tracking branch 'origin/main' into auto-title

# Conflicts:
#	packages/agent-core-v2/docs/state-manifest.d.ts
#	packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts
#	packages/node-sdk/src/sdk-rpc-client-v2.ts
This commit is contained in:
7Sageer 2026-07-31 11:47:28 +08:00
commit 531dbd3774
366 changed files with 13777 additions and 6345 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` | `setWorkDir` / `addAdditionalDir` / `removeAdditionalDir` / `resolve` | IWorkspaceContext.* | GET/POST |
| `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

@ -12,21 +12,23 @@ When writing business code you declare three things; the container handles the r
Classes talk only to interfaces and never care how an implementation is constructed.
## The three `LifecycleScope` tiers
## The four `LifecycleScope` tiers
Lifetimes form a tree, from longest to shortest:
```text
App (0) process-wide, single global instance
└── Session (1) one session
└── Agent (2) one agent
App (0) process-wide, single global instance
└── Workspace (1) one workspace handler (a materialized workspace root)
└── Session (2) one session
└── Agent (3) one agent
```
```ts
export enum LifecycleScope {
App = 0,
Session = 1,
Agent = 2,
Workspace = 1,
Session = 2,
Agent = 3,
}
```
@ -51,7 +53,7 @@ Deterministic: **child scopes die first; within one scope, instances dispose in
The `Ln` in a file-header identity line is the domain's **dependency layer** (L0L7), **not** its `LifecycleScope`. They are easy to confuse because both are small integers, but they answer different questions:
- `LifecycleScope` (App=0 / Session=1 / Agent=2) — **lifetime & visibility** (this stage).
- `LifecycleScope` (App=0 / Workspace=1 / Session=2 / Agent=3) — **lifetime & visibility** (this stage).
- Dependency layer `Ln` (L0L7) — **who may import whom**: a domain at layer `L` may import only domains at layer `<= L`. Enforced by `lint:domain` from the authoritative `DOMAIN_LAYER` map in `scripts/check-domain-layers.mjs`.
So a Session-scoped service is not "L1" — e.g. `session` is Session-scoped but lives at **L6**. When you write the header, read the number from the layer map, not from the scope.
@ -73,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).

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix sporadic "model is not configured" errors when starting kimi web, caused by the background provider-model refresh transiently clearing the model catalog while the first session was being created.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Enable Monaco-based highlighting for code blocks, and fix line numbers overlapping or drifting out of alignment in fallback-rendered code blocks.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Reduce frequent full-screen redraws in the TUI.

View file

@ -1,5 +0,0 @@
---
"kimi-code": patch
---
Reword the sign-in waiting message from "Waiting for authorization" to "Waiting for authentication".

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,8 @@ import {
IOAuthToolkit,
ISessionCronService,
ISessionIndex,
ISessionLifecycleService,
IWorkspaceHandlerService,
IWorkspaceLifecycleService,
ITelemetryService,
PRINT_MAX_TURNS_DEFAULT,
PRINT_WAIT_CEILING_S_DEFAULT,
@ -41,6 +42,7 @@ import {
bootstrap,
createCloudAppender,
ensureMainAgent,
resumeSessionById,
hostRequestHeadersSeed,
logSeed,
parseAgentFileText,
@ -256,7 +258,7 @@ async function resolveNativeSession(
defaultModel: string | undefined,
stderr: PromptOutput,
): Promise<ResolvedNativeSession> {
const lifecycle = app.accessor.get(ISessionLifecycleService);
const workspaceLifecycle = app.accessor.get(IWorkspaceLifecycleService);
const index = app.accessor.get(ISessionIndex);
// `--agent` selects a catalog profile by name; otherwise `--agent-file`
@ -304,7 +306,7 @@ async function resolveNativeSession(
};
const resumeById = async (id: string): Promise<ISessionScopeHandle> => {
const session = await lifecycle.resume(id);
const session = await resumeSessionById(app.accessor, id);
if (session === undefined) {
throw new Error(`Session "${id}" not found.`);
}
@ -374,7 +376,8 @@ async function resolveNativeSession(
}
const model = requireConfiguredModel(opts.model, defaultModel);
const session = await lifecycle.create({
const handler = await workspaceLifecycle.handlerFor({ root: workDir });
const session = await handler.accessor.get(IWorkspaceHandlerService).create({
workDir,
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
mainAgentBinding: {

View file

@ -16,7 +16,6 @@ import {
import { currentTheme } from '#/tui/theme';
import { createEditorTheme } from '#/tui/theme/pi-tui-theme';
import { printableChar } from '#/tui/utils/printable-key';
import { isInsideTmux } from '#/tui/utils/terminal-notification';
import { extractAtPrefix } from './file-mention-provider';
import { WrappingSelectList } from './wrapping-select-list';
@ -163,7 +162,6 @@ export class CustomEditor extends Editor {
private consumingPaste = false;
private consumeBuffer = '';
private argumentHints: ReadonlyMap<string, string> = new Map();
private autocompleteWasShowing = false;
setArgumentHints(hints: ReadonlyMap<string, string>): void {
this.argumentHints = hints;
@ -258,38 +256,7 @@ export class CustomEditor extends Editor {
(this as unknown as AutocompleteInternals).cancelAutocomplete();
}
// Force a full re-render when the autocomplete dropdown closes, so the editor
// snaps back to the bottom instead of sitting where the taller dropdown left it.
// Only worthwhile when the session content already overflows one screen; below
// that a full clear + home would pull the editor to the top and leave a blank
// tail. Always skipped inside tmux, whose own reflow handles the shrink.
private requestFullRenderOnAutocompleteClose(): void {
if (isInsideTmux()) return;
const { columns, rows } = this.tui.terminal;
// Redraw when content fills or overflows the viewport. An exact fill (==
// rows) is safe to clear (no blank tail) and still needs the redraw: the
// differential renderer keeps the old viewport offset after a shrink.
if (this.tui.render(columns).length < rows) return;
this.tui.requestRender(true);
}
// Detect an autocomplete open→close edge from a render frame and force a full
// re-render. Running from render() (not handleInput) also catches asynchronous
// closes — e.g. Backspace deleting the leading `/`, where pi-tui only cancels
// the menu once the provider re-query resolves. The render request is deferred
// to a microtask so the overflow probe inside the helper does not re-enter
// render() synchronously.
private trackAutocompleteCloseForFullRender(): void {
const showing = this.isShowingAutocomplete();
const closed = this.autocompleteWasShowing && !showing;
this.autocompleteWasShowing = showing;
if (closed) {
queueMicrotask(() => this.requestFullRenderOnAutocompleteClose());
}
}
override render(width: number): string[] {
this.trackAutocompleteCloseForFullRender();
const lines = super.render(width);
if (lines.length < 3) return lines;
const firstContentIdx = 1;

View file

@ -2052,11 +2052,10 @@ export class KimiTUI {
this.state.todoPanelContainer.clear();
this.imageStore.clear();
this.renderWelcome();
// Session resets (/new, /clear, session switch) want a pristine screen.
// Force a destructive full render: the renderer's collapse repaint
// intentionally preserves scrollback, which would leave the previous
// session's text above the welcome banner.
this.state.ui.requestRender(true);
// No forced full render on session reset: let the differential renderer
// converge on its own (a mass change above the viewport still makes the
// engine repaint everything, but nothing is forced destructively here).
this.state.ui.requestRender();
}
private isTurnBoundaryComponent(child: Component): boolean {
@ -2550,12 +2549,10 @@ export class KimiTUI {
if (!isExpandable(child)) continue;
child.setExpanded(this.state.toolOutputExpanded && i >= expandCutoff);
}
// Expanding/collapsing shifts content above the viewport; the clamped
// differential render would paint a second copy below the stale one in
// scrollback. This is a deliberate user action (like /clear), so do a
// destructive full render: scrollback holds exactly one copy and the
// expanded output can be read by scrolling up.
this.state.ui.requestRender(true);
// Differential render only — no destructive full redraw on expand/collapse.
// (When the expanded region reaches above the viewport, the engine's own
// fallback may still do a full render; that path is not forced from here.)
this.state.ui.requestRender();
}
toggleTodoPanelExpansion(): void {
@ -2793,18 +2790,10 @@ export class KimiTUI {
this.state.editorContainer.clear();
this.state.editorContainer.addChild(this.state.editor);
this.state.ui.setFocus(this.state.editor);
// Measure overflow against the restored tree (editor mounted), not the tall
// panel just removed — otherwise a short session with a tall panel looks like
// it overflows and we take a full clear/home that yanks the editor to the top.
// Treat an exact one-screen fill as overflowing too: a full redraw is safe
// there (no blank tail) and clears a stale viewport offset after a shrink.
const { columns, rows } = this.state.terminal;
const overflowsViewport = this.state.ui.render(columns).length >= rows;
// Force a full re-render after replacing a tall panel with the shorter editor:
// differential rendering leaves the editor shifted up when the bottom-anchored
// region shrinks in place. Skip under tmux (its own reflow handles the shrink)
// and when content fits on one screen (a full clear would pull the editor up).
this.state.ui.requestRender(!this.state.terminalState.insideTmux && overflowsViewport);
// Differential render only: closing a tall panel leaves the editor a few
// rows above the bottom (blank tail) until the next append, but avoids a
// destructive full redraw on every dialog close.
this.state.ui.requestRender();
}
restoreInputText(text: string): void {

View file

@ -20,7 +20,8 @@ import {
IOAuthToolkit,
ISessionCronService,
ISessionIndex,
ISessionLifecycleService,
IWorkspaceHandlerService,
IWorkspaceLifecycleService,
ISkillCatalogRuntimeOptions,
ITelemetryService,
type DomainEvent,
@ -179,6 +180,17 @@ function makeFakeHarness() {
]);
const session = fakeScope('ses_v2', sessionServices);
const handlerServices = new Map<unknown, unknown>([
[
IWorkspaceHandlerService,
{
create: vi.fn(async () => session),
resume: vi.fn(async () => session),
},
],
]);
const workspace = fakeScope('wd_v2', handlerServices);
const appServices = new Map<unknown, unknown>([
[
IConfigService,
@ -193,13 +205,25 @@ function makeFakeHarness() {
},
],
[
ISessionLifecycleService,
IWorkspaceLifecycleService,
{
create: vi.fn(async () => session),
resume: vi.fn(async () => session),
handlerFor: vi.fn(async () => workspace),
},
],
[
ISessionIndex,
{
list: vi.fn(async () => ({ items: [] })),
get: vi.fn(async (id: string) => ({
id,
workspaceId: 'wd_v2',
cwd: process.cwd(),
createdAt: 1,
updatedAt: 1,
archived: false,
})),
},
],
[ISessionIndex, { list: vi.fn(async () => ({ items: [] })) }],
[
IBootstrapService,
{
@ -232,7 +256,7 @@ function makeFakeHarness() {
],
]);
const app = fakeScope('app', appServices);
return { app, agent, session, agentServices, appServices, profileState };
return { app, agent, session, agentServices, appServices, handlerServices, profileState };
}
describe('runV2Print', () => {
@ -306,7 +330,7 @@ describe('runV2Print', () => {
it('seeds explicit agent files from --agentFile and binds the --agent profile', async () => {
const stdout = writer();
const stderr = writer();
const { app, agent, appServices, agentServices } = makeFakeHarness();
const { app, agent, appServices, agentServices, handlerServices } = makeFakeHarness();
mocks.bootstrap.mockReturnValue({ app });
mocks.ensureMainAgent.mockResolvedValue(agent);
@ -321,7 +345,7 @@ describe('runV2Print', () => {
const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions);
expect(seeded?.[1]).toMatchObject({ explicitFiles: ['/agents/reviewer.md'] });
const lifecycle = appServices.get(ISessionLifecycleService) as {
const lifecycle = handlerServices.get(IWorkspaceHandlerService) as {
create: ReturnType<typeof vi.fn>;
};
expect(lifecycle.create).toHaveBeenCalledWith({
@ -342,7 +366,7 @@ describe('runV2Print', () => {
);
const stdout = writer();
const stderr = writer();
const { app, agent, appServices, agentServices } = makeFakeHarness();
const { app, agent, appServices, agentServices, handlerServices } = makeFakeHarness();
mocks.bootstrap.mockReturnValue({ app });
mocks.ensureMainAgent.mockResolvedValue(agent);
@ -356,7 +380,7 @@ describe('runV2Print', () => {
const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions);
expect(seeded?.[1]).toMatchObject({ explicitFiles: [agentFile] });
const lifecycle = appServices.get(ISessionLifecycleService) as {
const lifecycle = handlerServices.get(IWorkspaceHandlerService) as {
create: ReturnType<typeof vi.fn>;
};
expect(lifecycle.create).toHaveBeenCalledWith({
@ -371,8 +395,8 @@ describe('runV2Print', () => {
it('does not materialize a main agent after fresh profile binding fails', async () => {
const stdout = writer();
const stderr = writer();
const { app, appServices } = makeFakeHarness();
const lifecycle = appServices.get(ISessionLifecycleService) as {
const { app, handlerServices } = makeFakeHarness();
const lifecycle = handlerServices.get(IWorkspaceHandlerService) as {
create: ReturnType<typeof vi.fn>;
};
lifecycle.create.mockRejectedValueOnce(new Error('Unknown agent profile'));

View file

@ -4,7 +4,7 @@ import type {
AutocompleteSuggestions,
TUI,
} from '@moonshot-ai/pi-tui';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { CustomEditor } from '#/tui/components/editor/custom-editor';
import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider';
@ -788,127 +788,3 @@ describe('CustomEditor bash mode file completion', () => {
expect(calls.every((call) => call.force === true)).toBe(true);
});
});
describe('CustomEditor full re-render on autocomplete close', () => {
function makeEditorWithRenderSpy(contentLines: number): {
editor: CustomEditor;
requestRender: ReturnType<typeof vi.fn>;
} {
const requestRender = vi.fn();
const tui = {
requestRender,
terminal: { rows: 40, cols: 120 },
render: vi.fn(() => Array.from({ length: contentLines }, () => '')),
} as unknown as TUI;
return { editor: new CustomEditor(tui), requestRender };
}
// Drive one render frame so the render-edge detector observes the menu state.
function renderFrame(editor: CustomEditor): void {
editor.render(120);
}
afterEach(() => {
vi.unstubAllEnvs();
});
it('forces a full re-render on the render frame after Escape closes the menu (content overflows)', async () => {
vi.stubEnv('TMUX', '');
const { editor, requestRender } = makeEditorWithRenderSpy(50);
editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }]));
editor.handleInput('/');
await flushAutocomplete();
expect(editor.isShowingAutocomplete()).toBe(true);
renderFrame(editor); // record wasShowing = true
editor.handleInput('');
expect(editor.isShowingAutocomplete()).toBe(false);
renderFrame(editor); // close edge -> schedule helper
await flushAutocomplete();
expect(requestRender).toHaveBeenCalledWith(true);
});
it('keeps differential rendering when the content fits on one screen', async () => {
vi.stubEnv('TMUX', '');
const { editor, requestRender } = makeEditorWithRenderSpy(10);
editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }]));
editor.handleInput('/');
await flushAutocomplete();
expect(editor.isShowingAutocomplete()).toBe(true);
renderFrame(editor);
editor.handleInput('');
expect(editor.isShowingAutocomplete()).toBe(false);
renderFrame(editor);
await flushAutocomplete();
expect(requestRender).not.toHaveBeenCalledWith(true);
});
it('forces a full re-render when the content exactly fills one screen', async () => {
vi.stubEnv('TMUX', '');
const { editor, requestRender } = makeEditorWithRenderSpy(40);
editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }]));
editor.handleInput('/');
await flushAutocomplete();
expect(editor.isShowingAutocomplete()).toBe(true);
renderFrame(editor);
editor.handleInput('');
expect(editor.isShowingAutocomplete()).toBe(false);
renderFrame(editor);
await flushAutocomplete();
expect(requestRender).toHaveBeenCalledWith(true);
});
it('does not force a full re-render inside tmux', async () => {
vi.stubEnv('TMUX', '/tmp/tmux-501/default,1234,0');
const { editor, requestRender } = makeEditorWithRenderSpy(50);
editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }]));
editor.handleInput('/');
await flushAutocomplete();
expect(editor.isShowingAutocomplete()).toBe(true);
renderFrame(editor);
editor.handleInput('');
expect(editor.isShowingAutocomplete()).toBe(false);
renderFrame(editor);
await flushAutocomplete();
expect(requestRender).not.toHaveBeenCalledWith(true);
});
it('forces a full re-render when Backspace deletes the slash and the menu closes asynchronously', async () => {
vi.stubEnv('TMUX', '');
const { editor, requestRender } = makeEditorWithRenderSpy(50);
const provider: AutocompleteProvider = {
getSuggestions: vi.fn(async (lines, cursorLine, cursorCol) => {
const text = (lines[cursorLine] ?? '').slice(0, cursorCol);
if (!text.startsWith('/')) return { items: [], prefix: text };
return { items: [{ value: 'help', label: 'help' }], prefix: '/' };
}),
applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ lines, cursorLine, cursorCol })),
};
editor.setAutocompleteProvider(provider);
editor.handleInput('/');
await flushAutocomplete();
expect(editor.isShowingAutocomplete()).toBe(true);
renderFrame(editor); // record wasShowing = true
editor.handleInput(''); // Backspace deletes the '/'
await flushAutocomplete();
await new Promise((resolve) => setTimeout(resolve, 0)); // let async cancelAutocomplete settle
expect(editor.isShowingAutocomplete()).toBe(false);
renderFrame(editor); // close edge -> schedule helper
await flushAutocomplete();
expect(requestRender).toHaveBeenCalledWith(true);
});
});

View file

@ -9,12 +9,15 @@
* the transcript audit and the agent inspector under Audit / Agent tabs;
* the `models` view is the full-width model catalog; the `services` view is
* the full-width app-scope Service reflection (`AppServicesView`); the
* `workspace` view is the workspace-scope counterpart
* (`WorkspaceServicesView`, with a workspace picker on top); the
* `bash` view is the full-width `IBashParserService` playground
* (`BashParserView`); the `search` view is the full-width global message
* search (`SearchView`) whose hits navigate back into the chat timeline.
*/
import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle';
import { ISessionIndex } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex';
import { IWorkspaceHandlerService } from '@moonshot-ai/agent-core-v2/workspace/workspaceHandler/workspaceHandler';
import { useEffect, useState } from 'react';
import type { AuditTrail } from './audit/trail';
@ -28,6 +31,7 @@ import { SearchView } from './components/SearchView';
import { ServerSwitcher } from './components/ServerSwitcher';
import { SessionPane } from './components/SessionPane';
import { Sidebar } from './components/Sidebar';
import { WorkspaceServicesView } from './components/WorkspaceServicesView';
import { useConnection } from './connection';
import type { SearchHit } from './search/api';
import { errorMessage } from './ui';
@ -45,15 +49,21 @@ export function App() {
const [jump, setJump] = useState<ChatJump | null>(null);
// Resume (materialize) the session on the server when it is selected, so
// session / agent scoped Services become reachable.
// session / agent scoped Services become reachable. Session lifecycle lives
// on the workspace handler (Workspace scope): the index yields the
// session's workspaceId, then the handler resumes it.
useEffect(() => {
if (sessionId === null) return;
let cancelled = false;
setReady(false);
setResumeError(null);
klient
.core(ISessionLifecycleService)
.resume(sessionId)
.core(ISessionIndex)
.get(sessionId)
.then((summary) => {
if (summary === undefined) throw new Error(`session ${sessionId} does not exist`);
return klient.workspace(summary.workspaceId).service(IWorkspaceHandlerService).resume(sessionId);
})
.then(() => {
if (!cancelled) setReady(true);
})
@ -104,6 +114,8 @@ export function App() {
<NavRail view={view} onChange={setView} />
{view === 'services' ? (
<AppServicesView />
) : view === 'workspace' ? (
<WorkspaceServicesView />
) : view === 'bash' ? (
<BashParserView />
) : view === 'models' ? (

View file

@ -19,7 +19,7 @@ import { DEBUG_RPC_BASE, type InspectClient } from './client';
import { RPCError } from './errors';
/** Wire scope kinds reported by the channels endpoint (`app` ≡ the core route). */
export type ChannelScope = 'app' | 'session' | 'agent';
export type ChannelScope = 'app' | 'workspace' | 'session' | 'agent';
/** Mirror of `ChannelDescriptor` in kap-server (`GET /api/v1/debug/channels`). */
export interface ChannelDescriptor {
@ -94,6 +94,7 @@ export async function probeDebugSurface(options: {
export interface ServiceTarget {
readonly scope: ChannelScope;
readonly workspaceId?: string;
readonly sessionId?: string;
readonly agentId?: string;
}
@ -103,7 +104,7 @@ export interface ServiceTarget {
* identifiers by name, so re-creating the decorator resolves to the same token
* the server channel registry created the name is the wire channel, which is
* all the proxy uses. Returns `undefined` when the target scope needs a
* session/agent id that isn't available.
* workspace/session/agent id that isn't available.
*/
export function serviceByName<T extends object>(
client: InspectClient,
@ -112,6 +113,10 @@ export function serviceByName<T extends object>(
): ServiceProxy<T> | undefined {
const id = createDecorator<T>(name);
if (target.scope === 'app') return client.core(id);
if (target.scope === 'workspace') {
if (target.workspaceId === undefined) return undefined;
return client.workspace(target.workspaceId).service(id);
}
if (target.sessionId === undefined) return undefined;
const base = client.session(target.sessionId);
if (target.scope === 'session') return base.service(id);

View file

@ -1,11 +1,12 @@
/**
* Inspect client the app's `/api/v1/debug` entry point, in the old-klient
* VS Code `ProxyChannel` model: a three-level scope entry (`core` /
* `session` / `agent`) whose every Service handle is a
* VS Code `ProxyChannel` model: a multi-level scope entry (`core` /
* `workspace` / `session` / `agent`) whose every Service handle is a
* `makeProxy`-materialized typed proxy over a service-bound HTTP channel.
*
* const client = createInspectClient({ url: 'http://127.0.0.1:58627' });
* await client.core(ISessionIndex).list({});
* await client.workspace('wd_1').service(IWorkspaceHandlerService).resume('s1');
* await client.session('s1').service(ISessionMetadata).read();
* await client.session('s1').agent('main').service(IAgentRPCService).cancel({});
*
@ -39,6 +40,7 @@ export interface InspectClient {
/** Bearer token in use, when any. */
readonly token?: string;
core<T extends object>(id: ServiceRef<T>): ServiceProxy<T>;
workspace(workspaceId: string): InspectAgentHandle;
session(sessionId: string): InspectSessionHandle;
}
@ -67,6 +69,9 @@ export function createInspectClient(options: InspectClientOptions): InspectClien
baseUrl: url,
token: options.token,
core: (id) => proxy('', id),
workspace: (workspaceId) => ({
service: (id) => proxy(`/workspace/${encodeURIComponent(workspaceId)}`, id),
}),
session: (sessionId) => {
const scopePath = `/session/${encodeURIComponent(sessionId)}`;
return {

View file

@ -18,7 +18,8 @@
*/
import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile';
import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle';
import { ISessionIndex } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex';
import { IWorkspaceHandlerService } from '@moonshot-ai/agent-core-v2/workspace/workspaceHandler/workspaceHandler';
import type { InspectionSource } from '@moonshot-ai/agent-core-v2/kosong/contract/inspection';
import type { TokenUsage } from '@moonshot-ai/agent-core-v2/kosong/contract/usage';
import {
@ -405,7 +406,13 @@ function ModelSection({
const envelope = (await res.json()) as { code: number; msg: string; data: { id: string } };
if (envelope.code !== 0) throw new Error(envelope.msg);
const sessionId = envelope.data.id;
await klient.core(ISessionLifecycleService).resume(sessionId);
const summary = await klient.core(ISessionIndex).get(sessionId);
if (summary !== undefined) {
await klient
.workspace(summary.workspaceId)
.service(IWorkspaceHandlerService)
.resume(sessionId);
}
await klient
.session(sessionId)
.agent('main')

View file

@ -6,7 +6,7 @@
import type { ReactNode } from 'react';
export type AppView = 'chat' | 'search' | 'models' | 'services' | 'bash';
export type AppView = 'chat' | 'search' | 'models' | 'services' | 'workspace' | 'bash';
interface ViewDef {
readonly id: AppView;
@ -68,6 +68,15 @@ const VIEWS: readonly ViewDef[] = [
</svg>
),
},
{
id: 'workspace',
title: 'Workspace Services',
icon: (
<svg {...iconProps}>
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
),
},
{
id: 'bash',
title: 'Bash Parser',

View file

@ -13,7 +13,7 @@ import {
ISessionIndex,
type SessionSummary,
} from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex';
import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle';
import { IWorkspaceHandlerService } from '@moonshot-ai/agent-core-v2/workspace/workspaceHandler/workspaceHandler';
import {
IWorkspaceService,
type Workspace,
@ -109,7 +109,13 @@ export function Sidebar({
try {
const model = await resolveDefaultModel(klient);
if (model !== undefined) {
await klient.core(ISessionLifecycleService).resume(sessionId);
const summary = await klient.core(ISessionIndex).get(sessionId);
if (summary !== undefined) {
await klient
.workspace(summary.workspaceId)
.service(IWorkspaceHandlerService)
.resume(sessionId);
}
await klient.session(sessionId).agent('main').service(IAgentProfileService).setModel(model);
}
} catch (error) {

View file

@ -0,0 +1,211 @@
/**
* WorkspaceDirBrowser server-side filesystem browser living in the Workspace
* Services view's left sidebar, replacing the old <select> of registered
* workspaces. Directories are listed through the App-scope
* `IHostFolderBrowser` (home + recent roots + one-level directory listing);
* entries that are registered workspaces carry a `workspace` badge plus their
* trust state (`trusted` / `untrusted`, read through the Workspace-scope
* `IWorkspaceTrust`; servers predating that service simply show no trust
* badge). "Select" registers the current folder on demand
* `IWorkspaceService.createOrTouch` is idempotent on root before handing
* the workspace to the parent.
*/
import {
IHostFolderBrowser,
type FsBrowseEntry,
} from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser';
import {
IWorkspaceService,
type Workspace,
} from '@moonshot-ai/agent-core-v2/app/workspace/workspace';
import { IWorkspaceTrust } from '@moonshot-ai/agent-core-v2/workspace/workspaceTrust/workspaceTrust';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import type { InspectClient } from '../channel';
import { ErrorLine } from '../ui';
function normalizePath(path: string): string {
return path.replaceAll('\\', '/').replace(/\/+$/, '');
}
function baseName(path: string): string {
const normalized = normalizePath(path);
return normalized.split('/').pop() ?? normalized;
}
export function WorkspaceDirBrowser(props: {
klient: InspectClient;
workspaces: readonly Workspace[] | undefined;
onSelect: (workspace: Workspace) => void;
}) {
const { klient, workspaces, onSelect } = props;
const queryClient = useQueryClient();
/** Current browsed directory; null = the server default ($HOME). */
const [path, setPath] = useState<string | null>(null);
const home = useQuery({
queryKey: ['fs-home', klient.baseUrl],
queryFn: () => klient.core(IHostFolderBrowser).home(),
});
const browse = useQuery({
queryKey: ['fs-browse', klient.baseUrl, path],
queryFn: () =>
path === null
? klient.core(IHostFolderBrowser).browse()
: klient.core(IHostFolderBrowser).browse(path),
});
const select = useMutation({
mutationFn: (root: string) => klient.core(IWorkspaceService).createOrTouch(root),
onSuccess: async (workspace) => {
await queryClient.invalidateQueries({ queryKey: ['workspaces', klient.baseUrl] });
onSelect(workspace);
},
});
const workspaceList = workspaces ?? [];
/** normalized root → trusted; undefined when the server predates `workspaceTrust`. */
const trustByRoot = useQuery({
queryKey: ['workspace-trust', klient.baseUrl, workspaceList.map((ws) => ws.id).join(',')],
enabled: workspaceList.length > 0,
retry: false,
queryFn: async () => {
const entries = await Promise.all(
workspaceList.map(async (ws) => {
try {
const trusted = await klient.workspace(ws.id).service(IWorkspaceTrust).get();
return [normalizePath(ws.root), trusted] as const;
} catch {
return [normalizePath(ws.root), undefined] as const;
}
}),
);
return new Map(entries);
},
});
const workspaceRoots = new Set(workspaceList.map((ws) => normalizePath(ws.root)));
const current = browse.data ?? null;
const recents = home.data?.recent_roots ?? [];
const isCurrentWorkspace = current !== null && workspaceRoots.has(normalizePath(current.path));
const renderEntry = (entry: FsBrowseEntry) => {
const isWorkspace = workspaceRoots.has(normalizePath(entry.path));
const trusted = trustByRoot.data?.get(normalizePath(entry.path));
return (
<button
key={entry.path}
type="button"
title={entry.path}
onClick={() => {
setPath(entry.path);
}}
className="flex w-full items-center gap-2 px-3 py-1 text-left hover:bg-neutral-900"
>
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-neutral-200">
{entry.name}/
</span>
{isWorkspace && trusted !== undefined ? (
<span
className={`shrink-0 rounded border px-1 text-[9px] font-semibold uppercase tracking-wider ${
trusted
? 'border-emerald-800 text-emerald-400'
: 'border-amber-800 text-amber-400'
}`}
>
{trusted ? 'trusted' : 'untrusted'}
</span>
) : null}
{isWorkspace ? (
<span className="shrink-0 rounded border border-sky-800 px-1 text-[9px] font-semibold uppercase tracking-wider text-sky-400">
workspace
</span>
) : null}
</button>
);
};
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-col gap-1 border-b border-neutral-800 px-3 py-2">
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => {
setPath(null);
}}
className="shrink-0 rounded border border-neutral-700 px-2 py-0.5 text-[10px] text-neutral-300 hover:border-sky-600"
>
home
</button>
<button
type="button"
disabled={!current?.parent}
onClick={() => {
if (current?.parent) setPath(current.parent);
}}
className="shrink-0 rounded border border-neutral-700 px-2 py-0.5 text-[10px] text-neutral-300 hover:border-sky-600 disabled:opacity-40"
>
up
</button>
<button
type="button"
disabled={current === null || select.isPending}
onClick={() => {
if (current !== null) select.mutate(current.path);
}}
className="ml-auto shrink-0 rounded border border-sky-700 px-2 py-0.5 text-[10px] font-semibold text-sky-300 hover:bg-sky-950 disabled:opacity-40"
>
{select.isPending ? 'selecting…' : isCurrentWorkspace ? 'select' : 'register & select'}
</button>
</div>
<span
title={current?.path}
className="truncate font-mono text-[10px] text-neutral-500"
>
{current?.path ?? '…'}
</span>
</div>
{recents.length > 0 ? (
<div className="flex flex-wrap items-center gap-1 border-b border-neutral-800 px-3 py-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-neutral-600">
recent
</span>
{recents.map((root) => (
<button
key={root}
type="button"
title={root}
onClick={() => {
setPath(root);
}}
className="max-w-32 truncate rounded border border-neutral-800 px-1.5 py-0.5 font-mono text-[10px] text-neutral-400 hover:border-sky-600 hover:text-neutral-200"
>
{baseName(root)}
</button>
))}
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto">
{browse.isError ? (
<div className="px-3 py-2">
<ErrorLine error={browse.error} />
</div>
) : current === null ? (
<div className="px-3 py-2 text-[11px] text-neutral-600 italic">loading</div>
) : current.entries.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-neutral-600 italic">no subdirectories</div>
) : (
current.entries.map(renderEntry)
)}
</div>
{select.isError ? (
<div className="border-t border-neutral-800 px-3 py-1">
<ErrorLine error={select.error} />
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,93 @@
/**
* Workspace Services view the workspace-scope Service reflection as a
* standalone rail view. Same Postman-style three-pane layout
* (`ScopePanelsScrollspy`) as App Services, plus a left sidebar holding the
* workspace picker: a server-side directory browser (`WorkspaceDirBrowser`,
* over the App-scope `IHostFolderBrowser`) that marks already-registered
* workspaces and registers a picked folder on demand. The proxies resolve on
* the `/workspace/:id` route, so a workspace must be selected before any
* Service is callable. Picking one materializes its handler on demand
* server-side (`IWorkspaceLifecycleService.handlerFor` is create-or-get), no
* manual join needed.
*/
import { IWorkspaceService } from '@moonshot-ai/agent-core-v2/app/workspace/workspace';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useState } from 'react';
import { serviceByName } from '../channel';
import { useConnection } from '../connection';
import type { AnyService } from '../panels';
import { ErrorLine } from '../ui';
import { ScopePanelsScrollspy } from './ServicePanels';
import { WorkspaceDirBrowser } from './WorkspaceDirBrowser';
export function WorkspaceServicesView() {
const { klient, baseUrl } = useConnection();
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
const workspaces = useQuery({
queryKey: ['workspaces', klient.baseUrl],
queryFn: () => klient.core(IWorkspaceService).list(),
});
// Switching servers invalidates the selection: workspaces belong to the
// server they were listed from.
useEffect(() => {
setWorkspaceId(null);
}, [baseUrl]);
const selected = (workspaces.data ?? []).find((ws) => ws.id === workspaceId);
const proxyFor = useCallback(
(name: string): AnyService | null =>
workspaceId === null
? null
: (serviceByName<AnyService>(klient, name, { scope: 'workspace', workspaceId }) ?? null),
[klient, workspaceId],
);
return (
<div className="flex min-h-0 min-w-0 flex-1">
<aside className="flex w-72 shrink-0 flex-col border-r border-neutral-800">
<div className="border-b border-neutral-800 px-3 py-2">
<div className="text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
Workspace
</div>
<div
title={selected?.root}
className="truncate font-mono text-[11px] text-neutral-300"
>
{selected === undefined ? (
<span className="text-neutral-600 italic">none selected</span>
) : (
`${selected.name}${selected.root}`
)}
</div>
{workspaces.isError ? <ErrorLine error={workspaces.error} /> : null}
</div>
<WorkspaceDirBrowser
klient={klient}
workspaces={workspaces.data}
onSelect={(workspace) => {
setWorkspaceId(workspace.id);
}}
/>
</aside>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{workspaceId === null ? (
<div className="flex flex-1 items-center justify-center p-6 text-[12px] text-neutral-600 italic">
select a workspace to inspect its Services
</div>
) : (
<ScopePanelsScrollspy
key={workspaceId}
scope="workspace"
title="Workspace Services"
proxyFor={proxyFor}
/>
)}
</div>
</div>
);
}

View file

@ -65,7 +65,7 @@ export interface ServicePanelDef {
readonly id: string;
readonly label: string;
/** Wire scope the Service is called on (`app` maps to the `core` route). */
readonly scope: 'app' | 'session' | 'agent';
readonly scope: 'app' | 'workspace' | 'session' | 'agent';
readonly fetch?: (svc: AnyService) => Promise<unknown>;
readonly actions?: readonly PanelAction[];
}

View file

@ -18,10 +18,13 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"katex": "^0.17.0",
"markstream-vue": "^1.0.7",
"markstream-vue": "^1.0.9-beta.1",
"mermaid": "^11.15.0",
"monaco-editor": "^0.55.1",
"shiki": "^4.3.0",
"stream-diffs": "^0.0.2",
"stream-markdown": "^0.0.16",
"stream-monaco": "^0.0.49",
"vue": "^3.5.35",
"vue-i18n": "^11.4.5"
},

View file

@ -327,7 +327,14 @@ const CODE_DARK_THEME = 'github-dark';
// blank placeholders. Pinning `loading` to false drops the skeleton entirely:
// the block renders its plain-text fallback immediately and shiki upgrades it to
// the highlighted version when the highlighter is ready. Streaming blocks are
// unaffected (their `stream` is true, so the skeleton gate was already false).
// unaffected (their `stream` is true, so the skeleton gate was already
// false).
// Chat code blocks show no gutter: line numbers eat 3+ characters of reading
// width and every chat block starts at line 1 anyway. stream-diffs derives its
// gutter from `lineNumbers === false` (boolean, not monaco's 'off' string).
// This rides inside codeBlockProps because markstream 1.0.7 only forwards the
// top-level codeBlockMonacoOptions to the 'monaco' renderer kind the 'shiki'
// kind's props object omits it, while codeBlockProps reach the same component.
const codeBlockProps = {
showHeader: true,
showCopyButton: true,
@ -336,6 +343,7 @@ const codeBlockProps = {
showCollapseButton: false,
showFontSizeButtons: false,
loading: false,
monacoOptions: { lineNumbers: false },
};
// Root cause for the "large session turns into code skeletons" failure:

View file

@ -5,6 +5,7 @@
### Patch Changes
- [#2393](https://github.com/MoonshotAI/kimi-code/pull/2393) [`6d0a046`](https://github.com/MoonshotAI/kimi-code/commit/6d0a046488edda56219961b253c4787abae7a113) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Fix new users getting stranded on "Model setup required" with no way back to sign-in when the first login finishes authorization but fails to complete model setup; the screen now offers a path back to the sign-in page so login can be retried.
- [#2402](https://github.com/MoonshotAI/kimi-code/pull/2402) [`0f3b106`](https://github.com/MoonshotAI/kimi-code/commit/0f3b106c4260ad626f66bc5c457a535d3163f2bc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Reword the sign-in waiting message from "Waiting for authorization" to "Waiting for authentication".
- Updated dependencies [[`40172c7`](https://github.com/MoonshotAI/kimi-code/commit/40172c7ca96ca981b043b793588dd32e898979fa)]:
- @moonshot-ai/kimi-code-sdk@0.15.0
@ -13,6 +14,7 @@
### Patch Changes
- [#1994](https://github.com/MoonshotAI/kimi-code/pull/1994) [`beeb964`](https://github.com/MoonshotAI/kimi-code/commit/beeb964393c8f9a38c2b1e2273e4415fc434b16d) Thanks [@RealKai42](https://github.com/RealKai42)! - Reduce webview streaming re-render churn: settled assistant messages no longer re-render on every streaming delta, and local images over 10MB are no longer inlined into the webview DOM.
- Updated dependencies [[`ec88d35`](https://github.com/MoonshotAI/kimi-code/commit/ec88d352e8f4dc5e8ffd1212f016138458f69893), [`b5efba7`](https://github.com/MoonshotAI/kimi-code/commit/b5efba7abcaf4041f81ec520097a61e6546e8c50), [`ce0e3ce`](https://github.com/MoonshotAI/kimi-code/commit/ce0e3ceb04223bdaad8e8931bad46eff561055b6), [`e458323`](https://github.com/MoonshotAI/kimi-code/commit/e45832398d0d9cad98dbad1cbf1e5b103a20aace)]:
- @moonshot-ai/kimi-code-sdk@0.14.0

View file

@ -6,6 +6,21 @@ outline: 2
This page documents the changes in each Kimi Code CLI release.
## 0.31.0 (2026-07-30)
### Features
- Support Markdown-defined custom agents on agent-core.
- Add the /secondary_model slash command to configure the secondary model used by subagents (experimental; enable it in /experiments first).
- Plugins can contribute custom agents, discovered automatically and available for sub-agent delegation.
- Plugins can contribute system prompt instructions through `systemPrompt` or `systemPromptPath` in `kimi.plugin.json`.
### Bug Fixes
- Remove the blocking `block`/`timeout` wait from the TaskOutput tool so checking a background task can no longer stall the conversation; it now always returns an immediate snapshot, and completion still arrives via automatic notification.
- Fix sessions missing from the session picker when their cached metadata predates the archived flag.
- Fix request headers not being passed correctly on some requests.
## 0.30.0 (2026-07-29)
### Features

View file

@ -6,6 +6,21 @@ outline: 2
本页记录 Kimi Code CLI 每个版本的变更内容。
## 0.31.02026-07-30
### 新功能
- TUI 支持 Markdown 定义的自定义 Agent。
- 新增 /secondary_model 斜杠命令,用于配置子 Agent 使用的辅助模型(实验性功能,需先在 /experiments 中开启)。
- 插件可贡献自定义 Agent自动发现并可用于子 Agent 委派。
- 插件可贡献系统提示词,通过 `kimi.plugin.json` 中的 `systemPrompt``systemPromptPath` 声明。
### 修复
- 移除 TaskOutput 工具的阻塞式 `block`/`timeout` 等待。
- 修复会话元数据缓存早于 archived 标记时会话选择器缺少会话的问题。
- 修复部分请求未能正确传递请求头的问题。
## 0.30.02026-07-29
### 新功能

View file

@ -162,7 +162,7 @@
inherit (finalAttrs) pname version src pnpmWorkspaces;
inherit pnpm;
fetcherVersion = 3;
hash = "sha256-bL1AaInlb8dE+ua7a6llvQWkibEwEzfI3oQW5IOpX6I=";
hash = "sha256-FNxf3Zpr+s6ubtLIqXCkVgecBGejGM4hovUNVRnNoX4=";
};
nativeBuildInputs = [

View file

@ -356,6 +356,11 @@ export class AcpServer implements Agent {
// record verbatim. The `@ts-expect-error` documents this contract;
// if the SDK ever switches from spread-passthrough to explicit field
// copy, this line breaks and we revisit the boundary.
// NOTE (workspace-domain consolidation): the passthrough reaches the
// v1 kernel only. The v2 engine has NO caller `mcpServers` channel on
// session create/resume (its MCP manager is per-workspace-handler, fed
// by config files and plugins only) — how ACP-supplied servers should
// reach the v2 engine is left to a future ACP-specific design.
const mcpServers = acpMcpServersToConfigs(params.mcpServers);
if (!this.conn) {
// Defensive: every code path that constructs `AcpServer` (the

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` / `workspaceAgentProfileLoader` / `workspaceInstructions` / `workspaceMcp` / `workspaceDirs` / `workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit` / `workspaceToolPolicy` / `workspaceTrust`) 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. Agent profiles follow the Contribution / Registry / Catalog extension point instead of a workspace catalog: the `workspaceAgentProfileLoader` domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit runtime files) and its Workspace-scope loaders (`workspace` / `user` / `plugin` / `extra` / `explicit`) register `AgentProfileContribution`s into the App-scope `IAgentProfileRegistry`, tagged with the handler's `workspaceId` (the registry dedups per source id; the App-scope `builtinAgentProfileLoader` contributes the code-defined profiles), and each Session-scope `sessionAgentProfileCatalog` projects the registry into the merged, name-deduped read view directly — its seed carries only the workspace key. `workspaceTrust` records the per-workspace trust marker (persisted under the home, keyed by `encodeWorkDirKey(root)`); while untrusted, `workspaceMcpConfig` skips the project-level MCP config files (`.mcp.json`, `.kimi-code/mcp.json`). 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

@ -14,12 +14,12 @@
# defaultPermissionMode src/agent/permissionMode/configSection.ts
# defaultPlanMode src/agent/plan/configSection.ts
# experimental src/app/flag/flag.ts
# extraAgentDirs src/app/agentFileCatalog/configSection.ts
# extraAgentDirs src/workspace/workspaceAgentProfileLoader/configSection.ts
# extraSkillDirs src/app/skillCatalog/configSection.ts
# hooks src/agent/externalHooks/configSection.ts
# image src/agent/media/configSection.ts
# loopControl src/agent/loop/configSection.ts
# mcp src/agent/mcp/configSection.ts
# mcp src/app/mcpConfig/configSection.ts
# mergeAllAvailableSkills src/app/skillCatalog/configSection.ts
# modelCatalog src/app/kosongConfig/configSection.ts
# models src/app/kosongConfig/configSection.ts
@ -106,7 +106,7 @@ default_plan_mode = false
# ##########################################################################
# extraAgentDirs (config.toml: extra_agent_dirs)
# owner: src/app/agentFileCatalog/configSection.ts
# owner: src/workspace/workspaceAgentProfileLoader/configSection.ts
# scope: core
# ##########################################################################
@ -167,7 +167,7 @@ extra_skill_dirs = []
# ##########################################################################
# mcp
# owner: src/agent/mcp/configSection.ts
# owner: src/app/mcpConfig/configSection.ts
# scope: core
# hooks: stripEnv
# env:

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

@ -15,44 +15,32 @@
// expand structurally; classes render as their public instance shape. The
// defining source file heads each group.
//
// External ambient types referenced but not expanded (from node_modules): Readable, Writable
//
// snapshot() returns JSON-safe deep copies of these values: Maps become plain
// objects (or [key, value] entry arrays when a key is not string/number), Sets
// become arrays, bigints become strings, functions are dropped, circular
// references become '(circular)', and class instances collapse to a '(ClassName)'
// marker — the wire shape of an entry is the JSON projection of the type here.
//
// Index (Session: 28 keys · Agent: 67 keys)
// Index (Session: 18 keys · Agent: 67 keys)
// Session
// cron.inFlight src/session/cron/sessionCronServiceImpl.ts
// cron.lastSeenAt src/session/cron/sessionCronServiceImpl.ts
// cron.parsedCache src/session/cron/sessionCronServiceImpl.ts
// cron.seededFromStore src/session/cron/sessionCronServiceImpl.ts
// cron.started src/session/cron/sessionCronServiceImpl.ts
// cron.tasks src/session/cron/sessionCronServiceImpl.ts
// interaction.nextId src/session/interaction/interactionService.ts
// interaction.pending src/session/interaction/interactionService.ts
// interaction.recentlyResolved src/session/interaction/interactionService.ts
// sessionActivity.current src/session/sessionActivity/sessionActivityService.ts
// sessionActivity.folds src/session/sessionActivity/sessionActivityService.ts
// sessionAgentProfileCatalog.contributions src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts
// sessionAgentProfileCatalog.merged src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts
// sessionFs.realRootsCache src/session/sessionFs/fsService.ts
// sessionFs.rgResolution src/session/sessionFs/fsService.ts
// sessionFsWatch.gitignoreLoaded src/session/sessionFs/fsWatchService.ts
// sessionFsWatch.pending src/session/sessionFs/fsWatchService.ts
// sessionFsWatch.rawCount src/session/sessionFs/fsWatchService.ts
// sessionFsWatch.truncated src/session/sessionFs/fsWatchService.ts
// sessionFsWatch.watched src/session/sessionFs/fsWatchService.ts
// sessionLog.rootLevel src/session/sessionLog/sessionLogService.ts
// sessionMetadata.data src/session/sessionMetadata/sessionMetadataService.ts
// sessionSkillCatalog.contributions src/session/sessionSkillCatalog/skillCatalogService.ts
// sessionSkillCatalog.merged src/session/sessionSkillCatalog/skillCatalogService.ts
// sessionToolPolicy.state src/session/sessionToolPolicy/sessionToolPolicyService.ts
// workspaceCommand.pendingMainInjections src/session/workspaceCommand/workspaceCommandService.ts
// workspaceContext.additionalDirs src/session/workspaceContext/workspaceContextService.ts
// workspaceContext.workDir src/session/workspaceContext/workspaceContextService.ts
// cron.inFlight src/session/cron/sessionCronServiceImpl.ts
// cron.lastSeenAt src/session/cron/sessionCronServiceImpl.ts
// cron.parsedCache src/session/cron/sessionCronServiceImpl.ts
// cron.seededFromStore src/session/cron/sessionCronServiceImpl.ts
// cron.started src/session/cron/sessionCronServiceImpl.ts
// cron.tasks src/session/cron/sessionCronServiceImpl.ts
// interaction.nextId src/session/interaction/interactionService.ts
// interaction.pending src/session/interaction/interactionService.ts
// interaction.recentlyResolved src/session/interaction/interactionService.ts
// sessionActivity.current src/session/sessionActivity/sessionActivityService.ts
// sessionActivity.folds src/session/sessionActivity/sessionActivityService.ts
// sessionLog.rootLevel src/session/sessionLog/sessionLogService.ts
// sessionMetadata.data src/session/sessionMetadata/sessionMetadataService.ts
// sessionSkillCatalog.contributions src/session/sessionSkillCatalog/skillCatalogService.ts
// sessionSkillCatalog.merged src/session/sessionSkillCatalog/skillCatalogService.ts
// sessionToolPolicy.state src/session/sessionToolPolicy/sessionToolPolicyService.ts
// workspaceContext.additionalDirs src/session/workspaceContext/workspaceContextService.ts
// workspaceContext.workDir src/session/workspaceContext/workspaceContextService.ts
// Agent
// activityView.background src/agent/activityView/activityViewService.ts
// activityView.current src/agent/activityView/activityViewService.ts
@ -176,156 +164,6 @@ export interface SessionStateSnapshot {
background: number;
lastTurnReason?: 'completed' | 'cancelled' | 'failed';
}>;
// src/session/sessionAgentProfileCatalog/sessionAgentProfileCatalogService.ts
'sessionAgentProfileCatalog.contributions': Map<string, {
readonly c: /* AgentProfileContribution — packages/agent-core-v2/src/app/agentFileCatalog/agentProfileSource.ts */ {
readonly profiles: readonly /* AgentProfile — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ {
readonly name: string;
readonly description?: string;
readonly whenToUse?: string;
readonly override?: boolean;
readonly tools?: readonly string[];
readonly disallowedTools?: readonly string[];
readonly subagents?: readonly string[];
readonly modelPreference?: 'primary' | 'secondary';
systemPrompt: (context: /* AgentProfileContext — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ {
readonly cwd?: string;
readonly cwdListing?: string;
readonly agentsMd?: string;
readonly additionalDirsInfo?: string;
readonly osKind?: string;
readonly shellName?: string;
readonly shellPath?: string;
readonly now?: string;
readonly skills?: string;
readonly skillActive?: boolean;
readonly pluginSections?: string;
readonly productName?: string;
readonly replyStyleGuide?: string;
[key: string]: unknown;
}) => string;
readonly promptPrefix?: (ctx: /* AgentProfilePromptPrefixContext — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ {
readonly cwd: string;
readonly runner: /* ISessionProcessRunner — packages/agent-core-v2/src/session/process/processRunner.ts */ {
readonly _serviceBrand: undefined;
exec: (args: readonly string[], options?: /* ProcessExecOptions — packages/agent-core-v2/src/session/process/processRunner.ts */ {
readonly cwd?: string;
readonly env?: Record<string, string>;
}) => Promise</* IProcess — packages/agent-core-v2/src/session/process/processRunner.ts */ {
readonly stdin: Writable;
readonly stdout: Readable;
readonly stderr: Readable;
readonly pid: number;
readonly exitCode: number | null;
wait: () => Promise<number>;
kill: (signal?: 'SIGABRT' | 'SIGALRM' | 'SIGBUS' | 'SIGCHLD' | 'SIGCONT' | 'SIGFPE' | 'SIGHUP' | 'SIGILL' | 'SIGINT' | 'SIGIO' | 'SIGIOT' | 'SIGKILL' | 'SIGPIPE' | 'SIGPOLL' | 'SIGPROF' | 'SIGPWR' | 'SIGQUIT' | 'SIGSEGV' | 'SIGSTKFLT' | 'SIGSTOP' | 'SIGSYS' | 'SIGTERM' | 'SIGTRAP' | 'SIGTSTP' | 'SIGTTIN' | 'SIGTTOU' | 'SIGUNUSED' | 'SIGURG' | 'SIGUSR1' | 'SIGUSR2' | 'SIGVTALRM' | 'SIGWINCH' | 'SIGXCPU' | 'SIGXFSZ' | 'SIGBREAK' | 'SIGLOST' | 'SIGINFO') => Promise<void>;
dispose: () => void | Promise<void>;
}>;
};
readonly log?: /* ILogger — packages/agent-core-v2/src/_base/log/log.ts */ {
error: (message: string, payload?: unknown) => void;
warn: (message: string, payload?: unknown) => void;
info: (message: string, payload?: unknown) => void;
debug: (message: string, payload?: unknown) => void;
child: (ctx: /* LogContext — packages/agent-core-v2/src/_base/log/log.ts */ {
[key: string]: unknown;
}) => /* ILogger — recursive (packages/agent-core-v2/src/_base/log/log.ts) */ unknown;
};
}) => Promise<string>;
readonly summaryPolicy?: /* AgentProfileSummaryPolicy — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ {
readonly minChars: number;
readonly continuationPrompt: string;
readonly retries: number;
};
}[];
readonly skipped?: readonly /* SkippedAgentFile — packages/agent-core-v2/src/app/agentFileCatalog/types.ts */ {
readonly path: string;
readonly reason: string;
}[];
readonly scannedRoots?: readonly string[];
};
readonly priority: number;
}>;
'sessionAgentProfileCatalog.merged': Map<string, /* AgentProfile — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ {
readonly name: string;
readonly description?: string;
readonly whenToUse?: string;
readonly override?: boolean;
readonly tools?: readonly string[];
readonly disallowedTools?: readonly string[];
readonly subagents?: readonly string[];
readonly modelPreference?: 'primary' | 'secondary';
systemPrompt: (context: /* AgentProfileContext — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ {
readonly cwd?: string;
readonly cwdListing?: string;
readonly agentsMd?: string;
readonly additionalDirsInfo?: string;
readonly osKind?: string;
readonly shellName?: string;
readonly shellPath?: string;
readonly now?: string;
readonly skills?: string;
readonly skillActive?: boolean;
readonly pluginSections?: string;
readonly productName?: string;
readonly replyStyleGuide?: string;
[key: string]: unknown;
}) => string;
readonly promptPrefix?: (ctx: /* AgentProfilePromptPrefixContext — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ {
readonly cwd: string;
readonly runner: /* ISessionProcessRunner — packages/agent-core-v2/src/session/process/processRunner.ts */ {
readonly _serviceBrand: undefined;
exec: (args: readonly string[], options?: /* ProcessExecOptions — packages/agent-core-v2/src/session/process/processRunner.ts */ {
readonly cwd?: string;
readonly env?: Record<string, string>;
}) => Promise</* IProcess — packages/agent-core-v2/src/session/process/processRunner.ts */ {
readonly stdin: Writable;
readonly stdout: Readable;
readonly stderr: Readable;
readonly pid: number;
readonly exitCode: number | null;
wait: () => Promise<number>;
kill: (signal?: 'SIGABRT' | 'SIGALRM' | 'SIGBUS' | 'SIGCHLD' | 'SIGCONT' | 'SIGFPE' | 'SIGHUP' | 'SIGILL' | 'SIGINT' | 'SIGIO' | 'SIGIOT' | 'SIGKILL' | 'SIGPIPE' | 'SIGPOLL' | 'SIGPROF' | 'SIGPWR' | 'SIGQUIT' | 'SIGSEGV' | 'SIGSTKFLT' | 'SIGSTOP' | 'SIGSYS' | 'SIGTERM' | 'SIGTRAP' | 'SIGTSTP' | 'SIGTTIN' | 'SIGTTOU' | 'SIGUNUSED' | 'SIGURG' | 'SIGUSR1' | 'SIGUSR2' | 'SIGVTALRM' | 'SIGWINCH' | 'SIGXCPU' | 'SIGXFSZ' | 'SIGBREAK' | 'SIGLOST' | 'SIGINFO') => Promise<void>;
dispose: () => void | Promise<void>;
}>;
};
readonly log?: /* ILogger — packages/agent-core-v2/src/_base/log/log.ts */ {
error: (message: string, payload?: unknown) => void;
warn: (message: string, payload?: unknown) => void;
info: (message: string, payload?: unknown) => void;
debug: (message: string, payload?: unknown) => void;
child: (ctx: /* LogContext — packages/agent-core-v2/src/_base/log/log.ts */ {
[key: string]: unknown;
}) => /* ILogger — recursive (packages/agent-core-v2/src/_base/log/log.ts) */ unknown;
};
}) => Promise<string>;
readonly summaryPolicy?: /* AgentProfileSummaryPolicy — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ {
readonly minChars: number;
readonly continuationPrompt: string;
readonly retries: number;
};
}>;
// src/session/sessionFs/fsService.ts
'sessionFs.realRootsCache': {
readonly key: string;
readonly roots: readonly string[];
} | undefined;
'sessionFs.rgResolution': /* RgResolution — packages/agent-core-v2/src/session/sessionFs/rgLocator.ts */ {
readonly path: string;
readonly source: /* RgResolutionSource — packages/agent-core-v2/src/session/sessionFs/rgLocator.ts */ 'system-path' | 'share-bin-cached';
} | null | undefined;
// src/session/sessionFs/fsWatchService.ts
'sessionFsWatch.gitignoreLoaded': boolean;
'sessionFsWatch.pending': /* FsChangeEntry — packages/agent-core-v2/src/session/sessionFs/fsWatch.ts */ {
path: string;
change: /* FsChangeAction — packages/agent-core-v2/src/session/sessionFs/fsWatch.ts */ 'created' | 'modified' | 'deleted';
kind: /* FsChangeKind — packages/agent-core-v2/src/session/sessionFs/fsWatch.ts */ 'file' | 'directory' | 'symlink';
size_delta?: number;
etag?: string;
}[];
'sessionFsWatch.rawCount': number;
'sessionFsWatch.truncated': boolean;
'sessionFsWatch.watched': Set<string>;
// src/session/sessionLog/sessionLogService.ts
'sessionLog.rootLevel': /* LogLevelState — packages/agent-core-v2/src/_base/log/logService.ts */ {
level: /* LogLevel — packages/agent-core-v2/src/_base/log/log.ts */ 'info' | 'off' | 'error' | 'warn' | 'debug';
@ -588,112 +426,6 @@ export interface SessionStateSnapshot {
'sessionToolPolicy.state': /* SessionToolPolicyState — packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicyService.ts */ {
readonly disabledTools: readonly string[];
};
// src/session/workspaceCommand/workspaceCommandService.ts
'workspaceCommand.pendingMainInjections': (/* ContextMessage — packages/agent-core-v2/src/agent/contextMemory/types.ts */ /* Message — packages/agent-core-v2/src/kosong/contract/message.ts */ {
readonly role: /* Role — packages/agent-core-v2/src/kosong/contract/message.ts */ 'user' | 'system' | 'assistant' | 'tool';
readonly name?: string;
readonly content: (/* ContentPart — packages/agent-core-v2/src/kosong/contract/message.ts */ /* TextPart — packages/agent-core-v2/src/kosong/contract/message.ts */ {
type: 'text';
text: string;
} | /* ThinkPart — packages/agent-core-v2/src/kosong/contract/message.ts */ {
type: 'think';
think: string;
encrypted?: string;
} | /* ImageURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ {
type: 'image_url';
imageUrl: {
url: string;
id?: string;
};
} | /* AudioURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ {
type: 'audio_url';
audioUrl: {
url: string;
id?: string;
};
} | /* VideoURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ {
type: 'video_url';
videoUrl: {
url: string;
id?: string;
};
})[];
readonly toolCalls: /* ToolCall — packages/agent-core-v2/src/kosong/contract/message.ts */ {
type: 'function';
id: string;
name: string;
arguments: string | null;
extras?: Record<string, unknown>;
_streamIndex?: string | number;
}[];
readonly toolCallId?: string;
readonly partial?: boolean;
readonly tools?: readonly /* Tool — packages/agent-core-v2/src/kosong/contract/tool.ts */ {
name: string;
description: string;
parameters: Record<string, unknown>;
deferred?: true;
}[];
} & {
readonly id?: string;
readonly providerMessageId?: string;
readonly origin?: /* UserPromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'user';
} | /* SkillActivationOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'skill_activation';
readonly activationId: string;
readonly skillName: string;
readonly skillArgs?: string;
readonly trigger: 'user-slash' | 'model-tool' | 'nested-skill';
readonly skillType?: string;
readonly skillPath?: string;
readonly skillSource?: 'project' | 'user' | 'extra' | 'builtin';
} | /* PluginCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'plugin_command';
readonly activationId: string;
readonly pluginId: string;
readonly commandName: string;
readonly commandArgs?: string;
readonly trigger: 'user-slash';
} | /* InjectionOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'injection';
readonly variant: string;
readonly ownerPromptId?: string;
} | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'shell_command';
readonly phase: 'input' | 'output';
readonly isError?: boolean;
} | /* CompactionSummaryOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'compaction_summary';
} | /* SystemTriggerOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'system_trigger';
readonly name: string;
} | /* TaskOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'task';
readonly taskId: string;
readonly status: /* AgentTaskStatus — packages/agent-core-v2/src/agent/task/types.ts */ 'completed' | 'failed' | 'running' | 'timed_out' | 'killed' | 'lost';
readonly notificationId: string;
} | /* CronJobOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'cron_job';
readonly jobId: string;
readonly cron: string;
readonly recurring: boolean;
readonly coalescedCount: number;
readonly stale: boolean;
} | /* CronMissedOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'cron_missed';
readonly count: number;
} | /* HookResultOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'hook_result';
readonly event: string;
readonly blocked?: boolean;
} | /* RetryOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'retry';
readonly trigger?: string;
};
readonly isError?: boolean;
readonly note?: string;
})[];
// src/session/workspaceContext/workspaceContextService.ts
'workspaceContext.additionalDirs': string[];
'workspaceContext.workDir': string;
@ -768,7 +500,7 @@ export interface AgentStateSnapshot {
readonly trigger?: string;
};
readonly phase: /* TurnPhase — packages/agent-core-v2/src/agent/activityView/activityView.ts */ 'running' | 'streaming' | 'tool_call' | 'retrying';
readonly stream?: 'assistant' | 'tool_call' | 'thinking';
readonly stream?: 'tool_call' | 'assistant' | 'thinking';
readonly step: number;
readonly ending: boolean;
readonly endingReason?: 'error' | 'aborted' | 'max_steps';
@ -813,7 +545,7 @@ export interface AgentStateSnapshot {
'activityView.lifecycle': /* ActivityViewLifecycle — packages/agent-core-v2/src/agent/activityView/activityView.ts */ 'ready' | 'disposed';
'activityView.turn': /* MutableTurn — packages/agent-core-v2/src/agent/activityView/activityViewService.ts */ {
phase: /* TurnPhase — packages/agent-core-v2/src/agent/activityView/activityView.ts */ 'running' | 'streaming' | 'tool_call' | 'retrying';
stream: 'assistant' | 'tool_call' | 'thinking' | undefined;
stream: 'tool_call' | 'assistant' | 'thinking' | undefined;
step: number;
ending: boolean;
endingReason: 'error' | 'aborted' | 'max_steps' | undefined;
@ -948,7 +680,7 @@ export interface AgentStateSnapshot {
readonly trigger?: string;
};
readonly phase: /* TurnPhase — packages/agent-core-v2/src/agent/activityView/activityView.ts */ 'running' | 'streaming' | 'tool_call' | 'retrying';
readonly stream?: 'assistant' | 'tool_call' | 'thinking';
readonly stream?: 'tool_call' | 'assistant' | 'thinking';
readonly step: number;
readonly ending: boolean;
readonly endingReason?: 'error' | 'aborted' | 'max_steps';
@ -1008,7 +740,7 @@ export interface AgentStateSnapshot {
'llmRequester.lastConfigLogSignature': string | undefined;
'llmRequester.mediaDegradedTurns': Set<number>;
'llmRequester.mediaStrippedTurns': Map<number, /* MediaStripSnapshot — packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts */ {
readonly "__@mediaStripSnapshotBrand@2658": undefined;
readonly "__@mediaStripSnapshotBrand@2244": undefined;
}>;
'llmRequester.turnConfigs': Map<number, /* TurnRequestConfig — packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts */ {
readonly resolved: /* ProfileModelContext — packages/agent-core-v2/src/agent/profile/profile.ts */ {

View file

@ -73,7 +73,6 @@
*/
interface ConfigUpdatePayload {
_name: 'config.update';
cwd?: string;
modelAlias?: string;
profileName?: string;
/** ThinkingEffort */
@ -445,7 +444,6 @@ interface PlanRevisionPayload {
*/
interface ProfileBindPayload {
_name: 'profile.bind';
cwd?: string;
modelAlias?: string;
profileName?: string;
/** ThinkingEffort */

View file

@ -85,6 +85,12 @@ const DOMAIN_LAYER = new Map([
// (`sessionId`/`workspaceId`/`sessionDir`/`metaScope`/`cwd`); a pure seed
// with no IO, so it sits in L1.
['sessionContext', 1],
// `sessionLifecycleHooks` is the per-session lifecycle hook-slots seed
// (created by the Workspace-scope `workspaceHandler`, registered into by
// Session-scope adapters such as `externalHooks`) plus the shared
// create-source/close-reason vocabulary; a pure contract with no IO, so it
// sits in L1 beside `sessionContext`.
['sessionLifecycleHooks', 1],
// `scopeContext` is the Agent-scope seeded immutable facts value
// (`agentId` plus a persistence scope helper); a pure seed with no IO, so it
// sits in L1 beside `sessionContext`.
@ -95,7 +101,22 @@ const DOMAIN_LAYER = new Map([
// `IHostFileSystem`; besides those host bridges it depends only on `_base`
// and the `errors` facade, so it sits in L1 beside the other host bridges.
['git', 1],
// `workspaceContext` covers two same-layer seeds: the Session-scope
// read-only workspace view (`session/workspaceContext`) and the
// Workspace-scope seeded handler facts (`workspace/workspaceContext`) —
// one domain name per scope tier, same pattern as `state`.
['workspaceContext', 1],
// `sessionInstructions` is the Session-scope seeded AGENTS.md provider
// contract (`ISessionInstructionsProvider`): a pure data + change-event
// injection contract (the Workspace-scope `workspaceInstructions` impl
// hands it to each session) with no IO, so it sits in L1 beside
// `sessionContext`.
['sessionInstructions', 1],
// `workspaceInfo` is the Session-scope seeded workspace-directory data
// contract (`ISessionWorkspaceInfo`): a pure data + change-event injection
// contract (the Workspace-scope `workspaceDirs` impl hands it to each
// session) with no IO, so it sits in L1 beside `sessionInstructions`.
['workspaceInfo', 1],
['protocol', 1],
['hooks', 1],
// `task` is the managed-concurrent-execution primitive (run + defer).
@ -131,8 +152,33 @@ const DOMAIN_LAYER = new Map([
['file', 2],
['config', 2],
['projectLocalConfig', 2],
['sessionFs', 2],
// `process` is the Session-scope process-runner CONTRACT
// (`ISessionProcessRunner`); the implementation moved to the Workspace
// scope (`workspaceProcess`) but the contract stays in the session domain
// so Session/Agent consumers keep importing it without crossing the
// Workspace-tier import ban.
['process', 2],
// `workspaceProcess` is the Workspace-scope `ISessionProcessRunner`
// implementation (default cwd = handler root); it consumes the `process`
// contract (L2) and the os process bridge (L1).
['workspaceProcess', 2],
// `workspaceGit` is the Workspace-scope git facade pinned to the handler
// root over the App-scope `git` service (L1).
['workspaceGit', 2],
// `workspaceToolPolicy` is the Workspace-scope owner of the os-level tool
// veto set (runtime capabilities keyed off the handler's os backend); it
// hands every session the `sessionToolPolicyGate` seed contract (L1).
['workspaceToolPolicy', 2],
// `workspaceTrust` is the Workspace-scope owner of the per-workspace trust
// marker that gates project-level config (first consumer:
// `workspaceMcpConfig`); its only collaborators are the handler's
// `workspaceContext` (L1) and the `persistence` document store.
['workspaceTrust', 2],
// `sessionToolPolicyGate` is the Session-scope seeded workspace tool-veto
// contract (`ISessionToolPolicyGate`): a pure data + change-event
// injection contract (the Workspace-scope `workspaceToolPolicy` impl hands
// it to each session) with no IO, so it sits in L1 beside `workspaceInfo`.
['sessionToolPolicyGate', 1],
['workspace', 2],
['workspaceAliases', 2],
['workspaceSessions', 2],
@ -142,12 +188,40 @@ const DOMAIN_LAYER = new Map([
['model', 2],
['sessionIndex', 2],
['sessionStore', 2],
// `mcpCore` is the scope-agnostic MCP connection core: the connection
// manager, the stdio/SSE/HTTP transport clients, the OAuth flow, the
// server-config schemas, and the `McpOAuthStore` port. It holds no file
// loading, no persistence implementation, and no engine-config reads —
// those live in the `mcpConfig` / `workspaceMcpConfig` wrappers (L5) — so
// its highest dependencies are `config`-adjacent L1L2 building blocks and
// the kosong contract (L0).
['mcpCore', 2],
// L3 — registries & capabilities
['tool', 3],
['skill', 3],
['skillCatalog', 3],
['sessionSkillCatalog', 3],
['sessionAgentProfileCatalog', 3],
// `workspaceSkillCatalog` is the Workspace-scope owner of skill discovery
// + merging (sources bound per handler, single-source incremental
// refresh); `workspaceAgentProfileLoader` is the Workspace-scope loader
// half of the agent-profile extension point (workspace / extra / explicit
// file scans registered into the App-scope `IAgentProfileRegistry`,
// tagged with the handler's workspaceId) — the merged read view their
// Session-scope counterparts (`sessionSkillCatalog` /
// `sessionAgentProfileCatalog`) project sits in L3 beside them.
['workspaceSkillCatalog', 3],
['workspaceAgentProfileLoader', 3],
// `workspaceDirs` is the Workspace-scope owner of the handler's shared
// additional-directory set (local.toml load + append, caller-dir union,
// fs-watch refresh); its highest dependency is `projectLocalConfig` (L2),
// so it sits in L3 beside the other Workspace-scope resource owners.
['workspaceDirs', 3],
// `workspaceFs` is the Workspace-scope fs surface (list/read/search/grep/
// git status/diff) plus the shared fs-watch fan-out; its highest
// dependency is `workspaceDirs` (L3 — the additional-dir set used for
// path confinement), so it sits in L3 beside it.
['workspaceFs', 3],
['sessionToolPolicy', 3],
['permissionGate', 3],
['toolApproval', 3],
@ -163,13 +237,12 @@ const DOMAIN_LAYER = new Map([
['record', 3],
['modelCatalog', 3],
['agentProfileCatalog', 3],
['agentFileCatalog', 3],
['hostIdentity', 3],
// L4 — agent behaviour
// `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],
@ -205,6 +278,10 @@ const DOMAIN_LAYER = new Map([
['edit', 4],
['llmRequester', 4],
['profile', 4],
// `workspaceInstructions` is the Workspace-scope AGENTS.md snapshot service
// (load once per handler, watch-driven reload): it consumes the `profile`
// domain's pure AGENTS.md loader, so it sits in L4 beside it.
['workspaceInstructions', 4],
['prompt', 4],
// `shellCommand` orchestrates user `!` commands through `toolRegistry` (L3),
// `contextMemory` / `prompt` (L4) and `eventBus` (L1); its highest dependency is L4.
@ -214,19 +291,49 @@ const DOMAIN_LAYER = new Map([
['web', 4],
// L5 — agent task management
['agentTask', 5],
// `mcp` is the Agent-scope MCP assembly layer: the per-agent tool mirror
// (`IAgentMcpService`), the `ExecutableTool` adapters and tool-result
// output pipeline (which reach `media`, L4), and the wire discovery
// records. The scope-agnostic connection core lives in `mcpCore` (L2).
['mcp', 5],
// `workspaceMcp` is the Workspace-scope owner of the handler's one shared
// MCP connection manager (connect at build, change-event-driven reconcile);
// it consumes the `mcpCore` domain's manager and the `workspaceMcpConfig`
// domain's effective server set, so it sits in L5 beside them.
['workspaceMcp', 5],
// `workspaceMcpConfig` is the Workspace-scope owner of the handler's
// effective MCP server config: it loads and merges the mcp.json files
// (through the os hostFs) with the plugin contributions, watches both
// sources, and publishes the reconciled diff; its highest dependencies are
// `plugin` (L3) and the `mcpCore` domain's config schema (L2), so it sits
// in L5 beside the other Workspace-scope resource owners.
['workspaceMcpConfig', 5],
// `mcpConfig` (App, L5) is the persistence wrapper over the `mcpCore`
// domain: it declares the `[mcp]` config section (schema + env bindings +
// stripEnv guard) and implements the domain's `McpOAuthStore` port over
// `IAtomicDocumentStore`. Same wrapper shape as `kosongConfig` over kosong.
['mcpConfig', 5],
['cron', 5],
// `btw` forks a single side-question sub-agent via `agentLifecycle`,
// parallel to how the `Agent` tool spawns child agents. Agent-scope, L5.
['btw', 5],
// L6 — coordination
['agentLifecycle', 6],
// `workspaceHandler` is the Workspace-scope anchor of one materialized
// workspace: it owns the session lifecycle (create/resume/fork/close) of
// that workspace's sessions as its child scopes — the re-scoped heir of
// the deleted App-scope `sessionLifecycle` domain — so it sits in L6.
['workspaceHandler', 6],
// `workspaceLifecycle` is the App-scope owner of the live handler registry
// (create-or-get + in-flight join, handlers never closed). It coordinates
// the `workspace` catalog, `sessionIndex`, and the `workspaceHandler`
// domain, so it sits in L6 beside them.
['workspaceLifecycle', 6],
// `subagent` drives turns on other agents (`run`) and hosts the
// requester-side run hook/event surface (`SubagentStart`/`SubagentStop`).
// Its highest real dependency is `agentLifecycle` (target lookup), so it
// sits in L6 beside it.
['subagent', 6],
['sessionLifecycle', 6],
['externalHooks', 6],
['externalHooksRunner', 6],
['sessionExport', 6],
@ -244,18 +351,11 @@ const DOMAIN_LAYER = new Map([
['sessionTitle', 6],
['session', 6],
['terminal', 6],
// `workspaceCommand` orchestrates session-level workspace mutations
// (`addAdditionalDir`): it reaches through `agentLifecycle` (L6) to the
// `main` agent's `contextMemory` (L4) to mirror the action's stdout, and
// delegates project-local config persistence to `projectLocalConfig` (L2).
// Its highest real dependency is `agentLifecycle`, so it sits in L6 beside
// the other coordination domains.
['workspaceCommand', 6],
// `sessionInit` runs the `/init` command: it reaches through `agentLifecycle`
// (L6) to spawn the `coder` sub-agent and to the `main` agent's `profile`
// (L4) / `systemReminder` (L4) / `wireRecord` (L4), and reloads `AGENTS.md`
// through `profile` (L4). Its highest real dependency is `agentLifecycle`,
// so it sits in L6 beside `workspaceCommand`.
// so it sits in L6 beside the other coordination domains.
['sessionInit', 6],
// L7 — boundary
['approval', 7],
@ -296,7 +396,7 @@ const V1_PACKAGE = '@moonshot-ai/agent-core';
* Scope directories introduced by the `src/{scope}/{domain}` layout. A path's
* first segment is a scope tier, not a domain; the domain is the next segment.
*/
const SCOPE_DIRS = new Set(['app', 'session', 'agent', 'persistence', 'os', 'kosong']);
const SCOPE_DIRS = new Set(['app', 'workspace', 'session', 'agent', 'persistence', 'os', 'kosong']);
/**
* Two-level scope directories: `persistence` and `os` use `{scope}/{tier}`
@ -305,6 +405,30 @@ const SCOPE_DIRS = new Set(['app', 'session', 'agent', 'persistence', 'os', 'kos
*/
const TWO_LEVEL_SCOPES = new Set(['persistence', 'os', 'kosong']);
/**
* Scope-direction hard constraint (red line): Session- and Agent-tier code
* must never import Workspace-tier code (`src/workspace/**`) or the App-tier
* workspace lifecycle domain (`src/app/workspaceLifecycle/**`). Workspace
* capabilities reach sessions only through session-domain contracts + scope
* seeds. The numeric layers cannot express this (the workspace domains sit
* at L6 beside their consumers), so it is checked directly.
*/
const SESSION_AGENT_TIERS = new Set(['session', 'agent']);
const WORKSPACE_LIFECYCLE_PREFIX = 'app/workspaceLifecycle/';
/**
* The scope tier (`app` / `workspace` / `session` / `agent` / ) of an
* absolute path under `src/`, or `undefined` for files outside the tier
* layout (top-level facades, kosong/persistence/os internals).
* @param {string} absPath
*/
function scopeTierOf(absPath) {
const rel = relative(SRC_ROOT, absPath);
if (rel.startsWith('..') || rel === '') return undefined;
const seg = rel.split(/[\\/]/)[0];
return SCOPE_DIRS.has(seg) ? seg : undefined;
}
/**
* Kosong-internal layer order: contract protocol provider/model.
* A lower layer never imports a higher one; `model` `provider`
@ -492,7 +616,6 @@ const ALLOWED_EXCEPTIONS = new Set([
// `tool`; the remaining upward import is a `loop` error/event helper.
'contextMemory>agentTask',
'llmRequester>session',
'loop>mcp',
// `registerMediaTools` (media, L4) imports the `ReadMediaFileTool`
// implementation from the `tools` domain (L7) to register it for media
// capability agents.
@ -509,7 +632,6 @@ const ALLOWED_EXCEPTIONS = new Set([
// projection side references the context message type by structure only.
'record>contextMemory',
'plugin>externalHooks',
'plugin>mcp',
'profile>session',
'replayBuilder>agentTask',
'replayBuilder>rpc',
@ -524,7 +646,6 @@ const ALLOWED_EXCEPTIONS = new Set([
'filestore>persistence/backends',
'process>os/backends',
'terminal>os/backends',
'sessionFs>os/backends',
'blobStore>persistence/backends',
// `sessionIndex` (L2) reads the `persistence_minidb_readmodel` experimental
// flag (L3) to switch session listings between the legacy N+1 disk read and
@ -643,6 +764,22 @@ export function checkSource(source, absFile) {
continue;
}
// Rule 2b: scope direction — Session/Agent tiers never import the
// Workspace tier (`src/workspace/**` or `src/app/workspaceLifecycle/**`).
const sourceTier = scopeTierOf(absFile);
if (SESSION_AGENT_TIERS.has(sourceTier)) {
const targetTier = scopeTierOf(targetAbs);
const targetRel = relative(SRC_ROOT, targetAbs).split(/[\\/]/).join('/');
if (targetTier === 'workspace' || targetRel.startsWith(WORKSPACE_LIFECYCLE_PREFIX)) {
violations.push({
file: absFile,
line,
message: `scope violation: '${sourceTier}' tier must not import Workspace-tier code ('${targetRel}' via '${specifier}') — workspace capabilities reach sessions only through session-domain contracts + scope seeds`,
});
continue;
}
}
// Rule 3b: kosong-internal layering. Runs even for same-domain imports
// because the provider/bases sub-boundary also bans same-domain targets
// (registries and contrib modules live beside the bases).

View file

@ -0,0 +1,71 @@
/**
* `_base/contribution` domain (L1) generic source-keyed contribution
* registry.
*
* The storage half of the Contribution / Registry / Catalog extension-point
* pattern: a *contribution* is a plain data structure offered by an outer
* contributor (a loader, a plugin, a code module); the *registry* stores at
* most one contribution per `sourceId` re-registering the same `sourceId`
* replaces the previous entry, which is the only dedup this layer performs.
* Content-level dedup (e.g. by item name), ordering, and merge rules are the
* Catalog's projection job, never the registry's. `register` returns a handle
* whose `dispose` unregisters but only the entry it registered, so a stale
* handle can never evict a newer re-registration. Every mutation fires
* `onDidChange` with the affected `sourceId` so catalogs can re-project.
*/
import { Disposable, type IDisposable } from '../di/lifecycle';
import { Emitter, type Event } from '../event';
export interface ContributionRegistration<T> {
readonly sourceId: string;
readonly priority: number;
readonly contribution: T;
}
export interface RegisterContributionOptions {
readonly priority?: number;
}
export class ContributionRegistry<T> extends Disposable {
private readonly registrations = new Map<string, ContributionRegistration<T>>();
private readonly onDidChangeEmitter = this._register(new Emitter<string>());
readonly onDidChange: Event<string> = this.onDidChangeEmitter.event;
register(
sourceId: string,
contribution: T,
options?: RegisterContributionOptions,
): IDisposable {
const registration: ContributionRegistration<T> = {
sourceId,
priority: options?.priority ?? 0,
contribution,
};
this.registrations.set(sourceId, registration);
this.onDidChangeEmitter.fire(sourceId);
let active = true;
return {
dispose: () => {
if (!active) return;
active = false;
if (this.registrations.get(sourceId) !== registration) return;
this.registrations.delete(sourceId);
this.onDidChangeEmitter.fire(sourceId);
},
};
}
unregister(sourceId: string): void {
if (!this.registrations.delete(sourceId)) return;
this.onDidChangeEmitter.fire(sourceId);
}
entries(): readonly ContributionRegistration<T>[] {
return [...this.registrations.values()];
}
get(sourceId: string): ContributionRegistration<T> | undefined {
return this.registrations.get(sourceId);
}
}

View file

@ -13,8 +13,9 @@ import { ServiceCollection } from './serviceCollection';
export enum LifecycleScope {
App = 0,
Session = 1,
Agent = 2,
Workspace = 1,
Session = 2,
Agent = 3,
}
export enum ScopeActivation {
@ -76,6 +77,7 @@ export interface IScopeHandle<K extends LifecycleScope = LifecycleScope> {
}
export type IAppScopeHandle = IScopeHandle<LifecycleScope.App>;
export type IWorkspaceScopeHandle = IScopeHandle<LifecycleScope.Workspace>;
export type ISessionScopeHandle = IScopeHandle<LifecycleScope.Session>;
export type IAgentScopeHandle = IScopeHandle<LifecycleScope.Agent>;

View file

@ -0,0 +1,54 @@
/**
* `_base` text helpers Markdown frontmatter parsing.
*
* Splits a Markdown document into its YAML frontmatter block and body. Pure
* text processing with no IO and no domain knowledge: skill definitions,
* agent-definition files, and plugin command files all share this one parser
* instead of depending on each other's domains for it. A document without a
* leading `---` fence parses as all body with `data: null`; an unterminated
* fence is a `FrontmatterError`.
*/
import { load as loadYaml } from 'js-yaml';
export class FrontmatterError extends Error {
constructor(message: string, cause?: unknown) {
super(message);
this.name = 'FrontmatterError';
if (cause !== undefined) {
Object.defineProperty(this, 'cause', { value: cause, configurable: true });
}
}
}
export interface ParsedFrontmatter {
readonly data: unknown;
readonly body: string;
}
const FENCE = '---';
export function parseFrontmatter(text: string): ParsedFrontmatter {
const lines = text.split(/\r?\n/);
if (lines[0]?.trim() !== FENCE) {
return { data: null, body: text };
}
const close = lines.findIndex((line, index) => index > 0 && line.trim() === FENCE);
if (close === -1) {
throw new FrontmatterError('Missing closing frontmatter fence');
}
const yamlText = lines.slice(1, close).join('\n').trim();
const body = lines.slice(close + 1).join('\n');
if (yamlText === '') {
return { data: {}, body };
}
try {
return { data: loadYaml(yamlText) ?? {}, body };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new FrontmatterError(message, error);
}
}

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

@ -0,0 +1,34 @@
/**
* Path-filter helpers pure string predicates, no IO.
*/
function normalizeSlashes(p: string): string {
return p.replaceAll('\\', '/');
}
/**
* A chokidar `ignored` predicate that prunes a recursive watch on `root`
* down to the given candidate subtrees (files or directories beneath it).
* The root itself, every ancestor of a candidate (so traversal can reach
* it), and every path inside a candidate pass the filter; everything else
* is ignored, so large sibling trees (dependency folders, VCS metadata)
* are never walked. This is the reliable way to watch candidates that may
* not exist yet: watching a missing path directly silently never fires
* when its parent directory is missing too.
*/
export function subtreeWatchFilter(
root: string,
candidates: readonly string[],
): (path: string) => boolean {
const normRoot = normalizeSlashes(root);
const normCandidates = candidates.map(normalizeSlashes);
return (p: string): boolean => {
const norm = normalizeSlashes(p);
if (norm === normRoot) return false;
for (const candidate of normCandidates) {
if (norm === candidate || norm.startsWith(`${candidate}/`)) return false;
if (candidate.startsWith(`${norm}/`)) return false;
}
return true;
};
}

View file

@ -49,3 +49,38 @@ export class IntervalTimer implements IDisposable {
this.cancel();
}
}
/**
* One-shot debounce primitive a disposable `setTimeout` wrapper.
*
* `TimeoutTimer` owns a single `setTimeout` handle: every `cancelAndSet`
* cancels the pending run and re-arms, so a burst of triggers collapses into
* one deferred run (watch-event debounce). `dispose` guarantees the handle is
* cleared. Mirrors VS Code's `TimeoutTimer`.
*/
export class TimeoutTimer implements IDisposable {
private handle: ReturnType<typeof setTimeout> | undefined;
cancel(): void {
if (this.handle !== undefined) {
clearTimeout(this.handle);
this.handle = undefined;
}
}
cancelAndSet(runner: () => void, timeoutMs: number): void {
this.cancel();
const handle = setTimeout(() => {
this.handle = undefined;
runner();
}, timeoutMs);
if (typeof handle === 'object' && handle !== null && 'unref' in handle) {
(handle as { unref: () => void }).unref();
}
this.handle = handle;
}
dispose(): void {
this.cancel();
}
}

View file

@ -2,9 +2,9 @@ import type { Tool as KosongTool } from '#/kosong/contract/tool';
import { createDecorator } from "#/_base/di/instantiation";
import { type IDisposable } from "#/_base/di/lifecycle";
import type { McpServerEntry } from './connection-manager';
import type { McpOAuthService } from '#/agent/mcp/oauth/service';
import type { MCPClient, MCPToolDefinition } from './types';
import type { McpServerEntry } from '#/mcpCore/connection-manager';
import type { McpOAuthService } from '#/mcpCore/oauth/service';
import type { MCPClient, MCPToolDefinition } from '#/mcpCore/types';
export interface McpResolvedServer {
readonly client: MCPClient;

View file

@ -8,7 +8,7 @@
import { z } from 'zod';
import { defineModel } from '#/wire/model';
import type { MCPToolDefinition } from './types';
import type { MCPToolDefinition } from '#/mcpCore/types';
export interface McpToolCollision {
readonly qualified: string;

View file

@ -1,8 +1,10 @@
/**
* `mcp` domain (L5) `IAgentMcpService` implementation.
*
* Mirrors the session-level MCP connection manager's server set into the
* agent's tool registry: registers qualified tools for connected servers,
* Mirrors the workspace-level shared MCP connection manager's server set
* into the agent's tool registry (the manager arrives through the seeded
* `ISessionMcpHandle` one manager per workspace handler, shared by every
* session and agent): registers qualified tools for connected servers,
* keeps them registered across reconnects, swaps in the OAuth tool for
* `needs-auth` servers, journals tool discoveries on the wire (queued until
* restore finishes), and publishes `mcp.server.status` / `tool.list.updated`
@ -32,11 +34,11 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { createMcpAuthTool } from '#/agent/mcp/tools/auth';
import { createMcpTool } from '#/agent/mcp/tools/mcp';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { ISessionMcpService } from '#/session/mcp/sessionMcp';
import type { McpServerEntry } from './connection-manager';
import { ISessionMcpHandle } from '#/session/mcp/sessionMcpHandle';
import type { McpServerEntry } from '#/mcpCore/connection-manager';
import { IAgentMcpService } from './mcp';
import { qualifyMcpToolName } from './tool-naming';
import type { MCPClient, MCPToolDefinition } from './types';
import { qualifyMcpToolName } from '#/mcpCore/tool-naming';
import type { MCPClient, MCPToolDefinition } from '#/mcpCore/types';
import { IWireService } from '#/wire/wire';
import {
McpDiscoveryModel,
@ -97,7 +99,7 @@ export class AgentMcpService extends Disposable implements IAgentMcpService {
private readonly pendingDiscoveries: Array<() => void> = [];
constructor(
@ISessionMcpService private readonly sessionMcp: ISessionMcpService,
@ISessionMcpHandle private readonly mcpHandle: ISessionMcpHandle,
@ISessionContext private readonly sessionContext: ISessionContext,
@IAgentToolRegistryService private readonly registry: IAgentToolRegistryService,
@IEventBus private readonly eventBus: IEventBus,
@ -136,32 +138,32 @@ export class AgentMcpService extends Disposable implements IAgentMcpService {
}
get oauthService() {
return this.sessionMcp.connectionManager().oauthService;
return this.mcpHandle.connectionManager.oauthService;
}
waitForInitialLoad(signal?: AbortSignal): Promise<void> {
return this.sessionMcp.connectionManager().waitForInitialLoad(signal);
return this.mcpHandle.connectionManager.waitForInitialLoad(signal);
}
initialLoadDurationMs(): number {
return this.sessionMcp.connectionManager().initialLoadDurationMs();
return this.mcpHandle.connectionManager.initialLoadDurationMs();
}
list() {
return this.sessionMcp.connectionManager().list();
return this.mcpHandle.connectionManager.list();
}
resolved(name: string) {
return this.sessionMcp.connectionManager().resolved(name);
return this.mcpHandle.connectionManager.resolved(name);
}
getRemoteServerUrl(name: string) {
return this.sessionMcp.connectionManager().getRemoteServerUrl(name);
return this.mcpHandle.connectionManager.getRemoteServerUrl(name);
}
async reconnect(name: string, signal?: AbortSignal): Promise<void> {
signal?.throwIfAborted();
await this.sessionMcp.connectionManager().reconnect(name);
await this.mcpHandle.connectionManager.reconnect(name);
signal?.throwIfAborted();
}
@ -180,13 +182,13 @@ export class AgentMcpService extends Disposable implements IAgentMcpService {
): Promise<MCPClient | undefined> {
const healed = this.resolved(serverName)?.client;
if (healed !== undefined && healed !== staleClient) return healed;
await this.sessionMcp.connectionManager().reconnectAndJoin(serverName);
await this.mcpHandle.connectionManager.reconnectAndJoin(serverName);
const current = this.resolved(serverName)?.client;
return current !== undefined && current !== staleClient ? current : undefined;
}
onStatusChange(listener: Parameters<IAgentMcpService['onStatusChange']>[0]) {
const unsubscribe = this.sessionMcp.connectionManager().onStatusChange(listener);
const unsubscribe = this.mcpHandle.connectionManager.onStatusChange(listener);
return {
dispose: unsubscribe,
};

View file

@ -1,70 +0,0 @@
/**
* `mcp` domain (L5) MCP OAuth credential store.
*
* Persists OAuth tokens, registered DCR client info, and discovery state for
* MCP HTTP servers through the `storage` access-pattern store
* (`IAtomicDocumentStore`) under the `credentials/mcp` scope
* (`<homeDir>/credentials/mcp/<key>-*.json`). One logical record per
* `(serverName, serverUrl)` identity, addressed by {@link mcpOAuthStoreKey}.
*
* Read semantics: missing or corrupt JSON resolves to `undefined` (never
* throws). The provider treats `undefined` as "not stored".
*/
import { createHash } from 'node:crypto';
import { basename } from 'pathe';
import type { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
const CREDENTIALS_SCOPE = 'credentials/mcp';
export function sanitizeStoreKey(name: string): string {
const safe = basename(name).replaceAll(/[^a-zA-Z0-9_-]/g, '_').replaceAll(/_+/g, '_');
if (safe.length === 0 || safe.startsWith('.')) {
throw new Error(`Invalid MCP OAuth store key: "${name}"`);
}
return safe;
}
export function canonicalMcpOAuthResource(serverUrl: string | URL): string {
const url = new URL(serverUrl);
url.hash = '';
return url.toString();
}
export function mcpOAuthStoreKey(serverName: string, serverUrl: string | URL): string {
const safeName = sanitizeStoreKey(serverName);
const resource = canonicalMcpOAuthResource(serverUrl);
const digest = createHash('sha256')
.update(serverName)
.update('\0')
.update(resource)
.digest('hex')
.slice(0, 24);
return `${safeName}-${digest}`;
}
export interface McpOAuthStore {
read<T>(key: string): Promise<T | undefined>;
write(key: string, data: unknown): Promise<void>;
remove(key: string): Promise<void>;
}
export function createMcpOAuthStore(docs: IAtomicDocumentStore): McpOAuthStore {
return {
async read<T>(key: string): Promise<T | undefined> {
try {
return await docs.get<T>(CREDENTIALS_SCOPE, key);
} catch {
return undefined;
}
},
write(key, data) {
return docs.set(CREDENTIALS_SCOPE, key, data);
},
remove(key) {
return docs.delete(CREDENTIALS_SCOPE, key);
},
};
}

View file

@ -36,7 +36,7 @@ import {
isModelAcceptedImageMime,
} from '#/agent/media/image-format-policy';
import { persistOriginalImage } from '#/agent/media/image-originals';
import type { MCPContentBlock, MCPToolResult } from './types';
import type { MCPContentBlock, MCPToolResult } from '#/mcpCore/types';
export interface McpOutputOptions {
readonly originalsDir?: string;

View file

@ -1,38 +0,0 @@
import type { McpServerConfig } from './config-schema';
import { loadMcpServers } from './config-loader';
export interface SessionMcpConfig {
readonly servers: Record<string, McpServerConfig>;
}
export interface ResolveSessionMcpConfigInput {
readonly cwd: string;
readonly homeDir?: string;
}
export async function resolveSessionMcpConfig(
input: ResolveSessionMcpConfigInput,
): Promise<SessionMcpConfig | undefined> {
const servers = await loadMcpServers({
cwd: input.cwd,
homeDir: input.homeDir,
});
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

@ -32,8 +32,8 @@ import {
type ExecutableToolResult,
} from '#/tool/toolContract';
import { toInputJsonSchema } from '#/tool/input-schema';
import { AlreadyAuthorizedError, type McpOAuthService } from '#/agent/mcp/oauth/service';
import { qualifyMcpToolName } from '#/agent/mcp/tool-naming';
import { AlreadyAuthorizedError, type McpOAuthService } from '#/mcpCore/oauth/service';
import { qualifyMcpToolName } from '#/mcpCore/tool-naming';
/**
* `ToolUpdate.customKind` emitted by the MCP auth tool when the OAuth

View file

@ -30,13 +30,13 @@ import { isAbortError } from '#/_base/utils/abort';
import type { ExecutableTool, ExecutableToolContext, ExecutableToolResult } from '#/tool/toolContract';
import { mcpResultToExecutableOutput } from '#/agent/mcp/output';
import type { MCPClient, MCPToolResult } from '#/agent/mcp/types';
import type { MCPClient, MCPToolResult } from '#/mcpCore/types';
import {
isMcpConnectionClosedError,
isMcpMalformedResultError,
isMcpTransportFailure,
probeMcpLiveness,
} from '#/agent/mcp/client-shared';
} from '#/mcpCore/client-shared';
interface McpToolOptions {
readonly originalsDir?: string;

View file

@ -1,4 +1,6 @@
import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks';
import { IGitService } from '#/app/git/git';
import type { IGitService as GitService } from '#/app/git/git';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import type { IHostEnvironment as HostEnvironment } from '#/os/interface/hostEnvironment';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
@ -9,7 +11,6 @@ import type {
} from '#/agent/permissionPolicy/types';
import {
fileAccesses,
findLocalGitWorkTreeMarker,
hasGitPathComponent,
isGitControlPath,
} from './path-utils';
@ -20,6 +21,7 @@ export class GitControlPathAccessAskPermissionPolicyService implements Permissio
constructor(
@IHostEnvironment private readonly env: HostEnvironment,
@ISessionWorkspaceContext private readonly workspace: WorkspaceContext,
@IGitService private readonly git: GitService,
) {}
async evaluate(
@ -36,7 +38,7 @@ export class GitControlPathAccessAskPermissionPolicyService implements Permissio
);
if (directGitAccess !== undefined) return { kind: 'ask' };
const marker = await findLocalGitWorkTreeMarker(cwd);
const marker = await this.git.findWorkTree(cwd);
if (marker === null) return undefined;
const access = accesses.find((fileAccess) =>
isGitControlPath(fileAccess.path, marker, pathClass),

View file

@ -1,5 +1,7 @@
import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks';
import { isWithinWorkspace } from '#/tool/path-access';
import { IGitService } from '#/app/git/git';
import type { IGitService as GitService } from '#/app/git/git';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import type { IHostEnvironment as HostEnvironment } from '#/os/interface/hostEnvironment';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
@ -8,10 +10,7 @@ import type {
PermissionPolicy,
PermissionPolicyResult,
} from '#/agent/permissionPolicy/types';
import {
findLocalGitWorkTreeMarker,
writeFileAccesses,
} from './path-utils';
import { writeFileAccesses } from './path-utils';
export class GitCwdWriteApprovePermissionPolicyService implements PermissionPolicy {
readonly name = 'git-cwd-write-approve';
@ -19,6 +18,7 @@ export class GitCwdWriteApprovePermissionPolicyService implements PermissionPoli
constructor(
@IHostEnvironment private readonly env: HostEnvironment,
@ISessionWorkspaceContext private readonly workspace: WorkspaceContext,
@IGitService private readonly git: GitService,
) {}
async evaluate(
@ -45,7 +45,7 @@ export class GitCwdWriteApprovePermissionPolicyService implements PermissionPoli
return undefined;
}
return (await findLocalGitWorkTreeMarker(cwd)) === null
return (await this.git.findWorkTree(cwd)) === null
? undefined
: { kind: 'approve' };
}

View file

@ -1,8 +1,7 @@
import { readFile, stat } from 'node:fs/promises';
import * as nodePath from 'node:path';
import * as posixPath from 'node:path/posix';
import * as win32Path from 'node:path/win32';
import type { GitWorkTree } from '#/app/git/workTree';
import type { ToolFileAccess } from '#/tool/toolContract';
import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks';
import {
@ -10,11 +9,6 @@ import {
type PathClass,
} from '#/tool/path-access';
export interface PermissionGitWorkTreeMarker {
readonly dotGitPath: string;
readonly controlDirPath: string;
}
export function fileAccesses(context: ResolvedToolExecutionHookContext): ToolFileAccess[] {
return (
context.execution.accesses?.filter((access): access is ToolFileAccess => access.kind === 'file') ??
@ -40,7 +34,7 @@ export function hasGitPathComponent(
export function isGitControlPath(
targetPath: string,
marker: PermissionGitWorkTreeMarker,
marker: GitWorkTree,
pathClass: PathClass,
): boolean {
return (
@ -53,24 +47,6 @@ export function defaultPathClass(): PathClass {
return process.platform === 'win32' ? 'win32' : 'posix';
}
export async function findLocalGitWorkTreeMarker(
cwd: string,
): Promise<PermissionGitWorkTreeMarker | null> {
if (cwd.length === 0 || !nodePath.isAbsolute(cwd)) return null;
let current = nodePath.normalize(cwd);
for (let depth = 0; depth < 256; depth += 1) {
const dotGitPath = nodePath.join(current, '.git');
const marker = await probeLocalGitMarker(dotGitPath, current);
if (marker !== null) return marker;
const parent = nodePath.dirname(current);
if (parent === current) return null;
current = parent;
}
return null;
}
function relativePathParts(targetPath: string, cwd: string, pathClass: PathClass): string[] {
return pathMod(pathClass)
.relative(cwd, targetPath)
@ -81,32 +57,3 @@ function relativePathParts(targetPath: string, cwd: string, pathClass: PathClass
function pathMod(pathClass: PathClass): typeof posixPath {
return pathClass === 'win32' ? win32Path : posixPath;
}
async function probeLocalGitMarker(
dotGitPath: string,
markerParent: string,
): Promise<PermissionGitWorkTreeMarker | null> {
try {
const markerStat = await stat(dotGitPath);
if (markerStat.isDirectory()) return { dotGitPath, controlDirPath: dotGitPath };
if (!markerStat.isFile()) return null;
const content = await readFile(dotGitPath, 'utf8');
const controlDirPath = parseLocalGitDir(content, markerParent);
return controlDirPath === undefined ? null : { dotGitPath, controlDirPath };
} catch {
return null;
}
}
function parseLocalGitDir(content: string, markerParent: string): string | undefined {
const stripped = content.codePointAt(0) === 0xfeff ? content.slice(1) : content;
const line = stripped.trimStart().split(/\r?\n/, 1)[0]?.trim();
if (line === undefined || !line.startsWith('gitdir:')) return undefined;
const rawPath = line.slice('gitdir:'.length).trim();
if (rawPath.length === 0) return undefined;
return nodePath.normalize(
nodePath.isAbsolute(rawPath) ? rawPath : nodePath.join(markerParent, rawPath),
);
}

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

@ -20,10 +20,10 @@ import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
import { IPluginService } from '#/app/plugin/plugin';
import type { EnabledPluginSessionStart } from '#/app/plugin/types';
import { PLUGIN_SKILL_SOURCE_ID } from '#/app/skillCatalog/skillSource';
import type { SkillCatalog, SkillDefinition } from '#/app/skillCatalog/types';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
import { PLUGIN_SKILL_SOURCE_ID } from '#/session/sessionSkillCatalog/pluginSkillSource';
import { IAgentPluginService } from './agentPlugin';

View file

@ -2,8 +2,15 @@
* `profile` domain (L4) system-prompt context assembly.
*
* Loads the AGENTS.md instruction hierarchy (user-level brand + generic files,
* then project-level files from the project root down to the cwd) and assembles
* then project-level files from the project root down to the cwd the root
* discovered through the `git` domain's work-tree probe) and assembles
* the {@link SystemPromptContext} bag consumed by `IAgentProfileService.useProfile`.
* `agentsMdWatchRoots` exposes the watch plan for the probed file set so the
* Workspace-scope `workspaceInstructions` service can watch exactly what the
* loader reads, and `prepareSystemPromptContext` accepts a
* `preloadedAgentsMd` snapshot so the
* agent-side profile service can inject that workspace snapshot instead of
* re-reading the files.
*
* Runs on top of the os `IHostFileSystem` (for `readText` / `stat` / `readdir`)
* plus the host's `homeDir` supplied together as a small `ProfileContextDeps`
@ -18,6 +25,7 @@
import { dirname, join, normalize } from 'pathe';
import { findGitWorkTree } from '#/app/git/workTree';
import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
import type { SystemPromptContext } from './profile';
@ -32,6 +40,8 @@ interface ProfileContextDeps {
readonly homeDir: string;
}
export type { ProfileContextDeps };
export interface PreparedSystemPromptContext extends SystemPromptContext {
readonly cwdListing?: string;
readonly agentsMd?: string;
@ -41,6 +51,11 @@ export interface PreparedSystemPromptContext extends SystemPromptContext {
export interface PrepareSystemPromptContextOptions {
readonly additionalDirs?: readonly string[];
/**
* A pre-loaded AGENTS.md result (e.g. the workspace instructions snapshot).
* When provided, the loader is not re-run the caller owns freshness.
*/
readonly preloadedAgentsMd?: LoadedAgentsMd;
}
export async function prepareSystemPromptContext(
@ -52,7 +67,9 @@ export async function prepareSystemPromptContext(
const additionalDirs = dedupeDirs(options?.additionalDirs ?? []);
const [cwdListing, agentsMdResult, additionalDirsInfo] = await Promise.all([
listDirectory(deps, workDir, { collapseHiddenDirs: true }),
loadAgentsMdForRoots(deps, brandHome, [workDir]),
options?.preloadedAgentsMd !== undefined
? Promise.resolve(options.preloadedAgentsMd)
: loadAgentsMdForRoots(deps, brandHome, [workDir]),
loadAdditionalDirsInfo(deps, additionalDirs),
]);
return {
@ -77,7 +94,9 @@ interface LoadedAgentsMd {
readonly warning: string | undefined;
}
async function loadAgentsMdForRoots(
export type { LoadedAgentsMd };
export async function loadAgentsMdForRoots(
deps: ProfileContextDeps,
brandHome: string | undefined,
workDirs: readonly string[],
@ -113,7 +132,7 @@ async function loadAgentsMdForRoots(
for (const workDir of workDirs) {
const rootWorkDir = normalize(workDir);
const projectRoot = await findProjectRoot(deps, rootWorkDir);
const projectRoot = (await findGitWorkTree(deps.fs, rootWorkDir))?.root ?? rootWorkDir;
const dirs = dirsRootToLeaf(rootWorkDir, projectRoot);
for (const dir of dirs) {
@ -137,6 +156,50 @@ async function loadAgentsMdForRoots(
return { content, warning };
}
/**
* The watch plan for {@link loadAgentsMdForRoots}: one entry per existing
* watch root with the candidate instruction files beneath it (brand dir,
* real home for the `.agents` generic files, and the project root for the
* rootleaf `.kimi-code/AGENTS.md` / `AGENTS.md` / `agents.md` chain)
* regardless of existence or the first-existing-wins precedence the loader
* applies, since a higher-precedence file appearing later must still
* invalidate the snapshot. Consumers watch each root recursively and filter
* events to the candidates (watching a missing file directly silently never
* fires when its parent directory is missing too).
*/
export interface AgentsMdWatchRoot {
readonly root: string;
readonly candidates: readonly string[];
}
export async function agentsMdWatchRoots(
deps: ProfileContextDeps,
workDir: string,
brandHome?: string,
): Promise<readonly AgentsMdWatchRoot[]> {
const realHome = deps.homeDir;
const brandDir = brandHome ?? join(realHome, '.kimi-code');
const plan: AgentsMdWatchRoot[] = [
{ root: brandDir, candidates: [join(brandDir, 'AGENTS.md')] },
{
root: realHome,
candidates: [join(realHome, '.agents', 'AGENTS.md'), join(realHome, '.agents', 'agents.md')],
},
];
const rootWorkDir = normalize(workDir);
const projectRoot = (await findGitWorkTree(deps.fs, rootWorkDir))?.root ?? rootWorkDir;
const projectCandidates: string[] = [];
for (const dir of dirsRootToLeaf(rootWorkDir, projectRoot)) {
projectCandidates.push(
join(dir, '.kimi-code', 'AGENTS.md'),
join(dir, 'AGENTS.md'),
join(dir, 'agents.md'),
);
}
plan.push({ root: projectRoot, candidates: projectCandidates });
return plan;
}
async function loadAdditionalDirsInfo(
deps: ProfileContextDeps,
additionalDirs: readonly string[],
@ -150,18 +213,6 @@ async function loadAdditionalDirsInfo(
return sections.join('\n\n');
}
async function findProjectRoot(deps: ProfileContextDeps, workDir: string): Promise<string> {
const initial = normalize(workDir);
let current = initial;
while (true) {
if (await pathExists(deps, join(current, '.git'))) return current;
const parent = dirname(current);
if (parent === current) return initial;
current = parent;
}
}
function dirsRootToLeaf(workDir: string, projectRoot: string): string[] {
const dirs: string[] = [];
let current = normalize(workDir);

View file

@ -38,7 +38,6 @@ export class ProfileError extends Error2 {
}
export interface AgentConfigData {
cwd: string;
modelAlias?: string;
modelCapabilities: ModelCapability;
profileName?: string;
@ -47,7 +46,6 @@ export interface AgentConfigData {
}
export type AgentConfigUpdateData = Partial<{
cwd: string;
modelAlias: string;
profileName: string;
thinkingLevel: string;
@ -67,7 +65,6 @@ export interface ProfileData extends AgentConfigData {
}
export type ProfileUpdateData = Partial<{
cwd: string;
modelAlias: string;
profileName: string;
thinkingLevel: string;
@ -77,7 +74,6 @@ export type ProfileUpdateData = Partial<{
}>;
export interface ProfileBindingSnapshot {
readonly cwd: string;
readonly modelAlias?: string;
readonly profileName?: string;
readonly thinkingLevel: string;
@ -88,8 +84,6 @@ export interface ProfileBindingSnapshot {
}
export interface ProfileServiceOptions {
readonly cwd?: string | (() => string | undefined);
readonly chdir?: (cwd: string) => void | Promise<void>;
readonly emitStatusUpdated?: () => void;
}
@ -117,7 +111,6 @@ export interface BindAgentInput {
readonly model?: string;
readonly thinking?: string;
readonly strictThinking?: boolean;
readonly cwd?: string;
}
export interface IAgentProfileService {

View file

@ -2,7 +2,7 @@
* `profile` domain (L3) wire Model (`ProfileModel`) and the `config.update`
* Op (`configUpdate`) for the agent's persistent configuration slice.
*
* Declares the persistent profile config `cwd`, `modelAlias`, `profileName`,
* Declares the persistent profile config `modelAlias`, `profileName`,
* the resolved base thinking effort, `systemPrompt`, and the profile
* `disallowedTools` denylist and `subagents` delegation allowlist as a wire
* Model (initial `defaultProfileModel()`), plus the single Op whose `apply` is
@ -18,10 +18,13 @@
* `modelCapabilities` is intentionally NOT in the Model it is
* derived live from `IModelCatalog` so resume never pins stale capabilities.
* Each `apply` returns the same reference when nothing changes so the wire's
* reference-equality gate stays quiet. The `chdir` side effect and the
* `agent.status.updated` emission are NOT part of `apply`: they run after
* reference-equality gate stays quiet. The `agent.status.updated` emission is
* NOT part of `apply`: it runs after
* `wire.dispatch` on the live path only, so `wire.replay` rebuilds the Model
* silently.
* silently. The agent's working directory is deliberately NOT part of the
* binding: it is always the session's frozen cwd, read from `sessionContext`
* at render time rather than persisted here. Legacy `profile.bind` records
* that still carry a `cwd` field replay fine the schema strips it.
*
* Also declares `ActiveToolsModel` (`readonly string[] | undefined`, initial
* `undefined` = every tool active), the `tools.set_active_tools` whole-set
@ -42,7 +45,6 @@ import type { PayloadOf } from '#/wire/types';
import { ProfileError, ProfileErrors } from './profile';
export interface ProfileModelState {
readonly cwd?: string;
readonly modelAlias?: string;
readonly profileName?: string;
readonly thinkingLevel: string;
@ -58,7 +60,6 @@ export const ProfileModel = defineModel<ProfileModelState>('profile', () => ({
export const profileBind = ProfileModel.defineOp('profile.bind', {
schema: z.object({
cwd: z.string().optional(),
modelAlias: z.string().optional(),
profileName: z.string().optional(),
thinkingEffort: z.custom<ThinkingEffort>(),
@ -68,7 +69,6 @@ export const profileBind = ProfileModel.defineOp('profile.bind', {
subagents: z.array(z.string()).readonly().optional(),
}),
apply: (s, p) => ({
cwd: p.cwd ?? s.cwd,
modelAlias: p.modelAlias ?? s.modelAlias,
profileName: p.profileName ?? s.profileName,
thinkingLevel: p.thinkingEffort,
@ -80,7 +80,6 @@ export const profileBind = ProfileModel.defineOp('profile.bind', {
export const configUpdate = ProfileModel.defineOp('config.update', {
schema: z.object({
cwd: z.string().optional(),
modelAlias: z.string().optional(),
profileName: z.string().optional(),
thinkingEffort: z.custom<ThinkingEffort>().optional(),
@ -90,9 +89,6 @@ export const configUpdate = ProfileModel.defineOp('config.update', {
}),
apply: (s, p) => {
let next: ProfileModelState | undefined;
if (p.cwd !== undefined && p.cwd !== s.cwd) {
next = { ...(next ?? s), cwd: p.cwd };
}
if (p.modelAlias !== undefined && p.modelAlias !== s.modelAlias) {
next = { ...(next ?? s), modelAlias: p.modelAlias };
}

View file

@ -21,9 +21,9 @@
* so no restore-ordering coupling with `userTool` arises. Profile and client
* policy are persisted independently. The `agent.status.updated`
* / `warning` events now ride `IEventBus` (`agent.status.updated` canonical in
* `usageOps`). `chdir` and
* `emitStatusUpdated` run live-only after the dispatch, so `wire.replay`
* rebuilds the Models silently; the same live-only path mirrors the resolved
* `usageOps`). `emitStatusUpdated` runs live-only after the dispatch, so
* `wire.replay` rebuilds the Models silently; the same live-only path mirrors
* the resolved
* model protocol into the ambient telemetry context (`provider_type` /
* `protocol`) whenever the model alias changes.
* `bind()` is first-bind only a profile is the session's identity: the
@ -31,11 +31,16 @@
* in the synchronous segment before the first dispatch, so concurrent binds
* cannot both pass (an edge-level guard always leaves an interleaving
* window); a same-name rebind keeps the persisted thinking effort unless the
* caller explicitly overrides it. Prompt builds inject the enabled plugins'
* caller explicitly overrides it. The AGENTS.md portion of the system-prompt
* context comes from the seeded `ISessionInstructionsProvider` (the
* workspace handler's shared, watch-refreshed snapshot the working
* directory is always the session's frozen cwd, so the snapshot always
* applies), and the provider's change event drives a `refreshSystemPrompt`. Prompt builds inject the enabled plugins'
* system-prompt sections (budget-capped, see `PLUGIN_SECTIONS_MAX_BYTES`);
* plugin changes reach the prompt when the session skill catalog re-pulls
* its plugin source on explicit plugin reload the same point where plugin
* skills take effect. `refreshSystemPrompt` never rejects: a
* plugin changes reach the prompt when the skill catalog re-pulls its plugin
* source on explicit plugin reload (the Workspace-scope catalog forwards the
* plugin source's change through the session seed) the same point where
* plugin skills take effect. `refreshSystemPrompt` never rejects: a
* failed context build keeps the current prompt and surfaces a warning,
* because the `[tools]` config watcher fires it voided (an unhandled
* rejection would crash kap-server) and the Session tool-policy fan-out
@ -50,7 +55,7 @@
* The mutable plain-data state (`activeToolNamesOverlay` / `agentsMdWarning`
* / the three emitted-warning dedupe sets) is registered into `agentState`
* (`IAgentStateService`) and read/written through it; `optionsValue` (holds
* the `cwd` / `chdir` / `emitStatusUpdated` callbacks) and `activeProfile`
* the `cwd` / `emitStatusUpdated` callbacks) and `activeProfile`
* (a `ResolvedAgentProfile` carrying the `systemPrompt` function) stay plain
* fields because the container only holds pure data structures. Bound at
* Agent scope.
@ -76,7 +81,8 @@ import {
type ThinkingConfig,
} from '#/kosong/model/thinking';
import { THINKING_SECTION } from '#/app/kosongConfig/configSection';
import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog';
import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog';
import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader';
import { ErrorCodes, Error2 } from "#/errors";
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
@ -86,10 +92,12 @@ import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import type { ToolSource } from '#/tool/toolContract';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
import { PLUGIN_SKILL_SOURCE_ID } from '#/session/sessionSkillCatalog/pluginSkillSource';
import { PLUGIN_SKILL_SOURCE_ID } from '#/app/skillCatalog/skillSource';
import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog';
import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy';
import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate';
import { IPluginService } from '#/app/plugin/plugin';
import type { ResolvedAgentProfile, SystemPromptContext } from '#/agent/profile/profile';
import { IAgentStateService } from '#/agent/state/agentState';
@ -100,7 +108,7 @@ import { IWireService } from '#/wire/wire';
import type { PayloadOf } from '#/wire/types';
import { IEventBus } from '#/app/event/eventBus';
import { IHostIdentity } from '#/app/hostIdentity/hostIdentity';
import { prepareSystemPromptContext } from './context';
import { prepareSystemPromptContext, type LoadedAgentsMd } from './context';
import type {
ApplyProfileOptions,
BindAgentInput,
@ -206,9 +214,11 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
@ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,
@ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog,
@ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog,
@ISessionInstructionsProvider private readonly instructions: ISessionInstructionsProvider,
@ISessionToolPolicy private readonly sessionToolPolicy: ISessionToolPolicy,
@ISessionToolPolicyGate private readonly toolPolicyGate: ISessionToolPolicyGate,
@IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService,
@IAgentProfileCatalogService private readonly builtinProfiles: IAgentProfileCatalogService,
@IBuiltinAgentProfileLoader private readonly builtinProfiles: IBuiltinAgentProfileLoader,
@IAgentStateService private readonly states: IAgentStateService,
@IHostIdentity private readonly hostIdentity: IHostIdentity,
@IPluginService private readonly plugins: IPluginService,
@ -225,6 +235,13 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
event.waitUntil(this.refreshSystemPrompt());
}),
);
this._register(
// The workspace AGENTS.md snapshot changed (fs watch on the handler
// side): rebuild the system prompt off the fresh snapshot.
this.instructions.onDidChange(() => {
void this.refreshSystemPrompt();
}),
);
this._register(
this.config.onDidSectionChange(({ domain }) => {
if (domain === TOOLS_SECTION) {
@ -272,8 +289,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
configure(options: ProfileServiceOptions): void {
this.optionsValue = {
cwd: options.cwd ?? this.optionsValue.cwd,
chdir: options.chdir ?? this.optionsValue.chdir,
emitStatusUpdated: options.emitStatusUpdated ?? this.optionsValue.emitStatusUpdated,
};
}
@ -300,7 +315,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
this.activeToolNamesOverlay = undefined;
this.wire.dispatch(
profileBind({
cwd: snapshot.cwd,
modelAlias: snapshot.modelAlias,
profileName: snapshot.profileName,
thinkingEffort: snapshot.thinkingLevel,
@ -311,7 +325,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
}),
);
this.afterConfigDispatch({
cwd: snapshot.cwd,
modelAlias: snapshot.modelAlias,
profileName: snapshot.profileName,
thinkingLevel: snapshot.thinkingLevel,
@ -349,7 +362,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
}
await this.sessionToolPolicy.ready;
const context = await this.buildSystemPromptContext(profile, input.cwd);
const context = await this.buildSystemPromptContext(profile);
this.assertBindable(profile.name);
const currentProfileName = this.profileName;
const systemPrompt = profile.systemPrompt(context);
@ -363,7 +376,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
this.activeToolNamesOverlay = undefined;
this.wire.dispatch(profileBind({
cwd: input.cwd,
modelAlias: alias,
profileName: profile.name,
thinkingEffort: thinkingLevel,
@ -373,7 +385,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
subagents: profile.subagents,
}));
this.afterConfigDispatch({
cwd: input.cwd,
modelAlias: alias,
profileName: profile.name,
thinkingLevel,
@ -445,7 +456,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
}
async applyProfile(profile: ResolvedAgentProfile, options?: ApplyProfileOptions): Promise<void> {
const context = await this.buildSystemPromptContext(profile, undefined, options);
const context = await this.buildSystemPromptContext(profile, options);
this.useProfile(profile, context);
this.cacheAgentsMdWarning(context);
this.publishAgentsMdWarning();
@ -458,7 +469,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
let context: SystemPromptContext;
try {
context = await this.buildSystemPromptContext(profile, this.cwd);
context = await this.buildSystemPromptContext(profile);
} catch (error) {
this.eventBus.publish({
type: 'warning',
@ -483,7 +494,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
data(): ProfileData {
const model = this.tryResolveRawModel();
return {
cwd: this.cwd,
modelAlias: this.modelAlias,
modelCapabilities: model?.capabilities ?? UNKNOWN_CAPABILITY,
profileName: this.profileName,
@ -583,7 +593,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
const payload: {
-readonly [K in keyof PayloadOf<typeof configUpdate>]: PayloadOf<typeof configUpdate>[K];
} = {};
if (changed.cwd !== undefined) payload.cwd = changed.cwd;
if (changed.modelAlias !== undefined) payload.modelAlias = changed.modelAlias;
if (changed.profileName !== undefined) payload.profileName = changed.profileName;
if (changed.thinkingLevel !== undefined || changed.modelAlias !== undefined) {
@ -600,9 +609,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
}
private afterConfigDispatch(changed: Omit<ProfileUpdateData, 'activeToolNames'>): void {
if (changed.cwd !== undefined) {
void this.optionsValue.chdir?.(changed.cwd);
}
if (changed.modelAlias !== undefined) {
const model = this.tryResolveRawModel();
this.telemetryContext.set({
@ -678,10 +684,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
return this.wire.getModel(ProfileModel);
}
private get cwd(): string {
return this.profileState.cwd ?? this.readConfiguredCwd() ?? '';
}
private get model(): string {
const modelAlias = this.modelAlias;
if (modelAlias === undefined) {
@ -859,21 +861,26 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
private async buildSystemPromptContext(
profile: ResolvedAgentProfile,
cwd?: string,
options?: ApplyProfileOptions,
): Promise<SystemPromptContext> {
const effectiveCwd = cwd ?? this.sessionContext.cwd;
// The working directory is always the session's frozen cwd, so the
// workspace instructions snapshot (which covers the handler root) always
// applies.
const preloadedAgentsMd = await this.workspaceInstructionsSnapshot();
const base = await prepareSystemPromptContext(
{ fs: this.fs, homeDir: this.env.homeDir },
effectiveCwd,
this.sessionContext.cwd,
this.bootstrap.homeDir,
{ additionalDirs: options?.additionalDirs ?? this.workspace.additionalDirs },
{
additionalDirs: options?.additionalDirs ?? this.workspace.additionalDirs,
preloadedAgentsMd,
},
);
const skills = await this.resolveSkillListing();
const pluginSections = await this.resolvePluginSections();
return {
...base,
cwd: effectiveCwd,
cwd: this.sessionContext.cwd,
osKind: this.env.osKind,
shellName: this.env.shellName,
shellPath: this.env.shellPath,
@ -886,6 +893,14 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
};
}
private async workspaceInstructionsSnapshot(): Promise<LoadedAgentsMd> {
await this.instructions.ready;
return {
content: this.instructions.agentsMd ?? '',
warning: this.instructions.agentsMdWarning,
};
}
private isToolActiveForProfile(
profile: ResolvedAgentProfile,
name: string,
@ -893,6 +908,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
): boolean {
return isToolActiveComposed(
{
workspaceDisabledTools: this.toolPolicyGate.disabledTools,
profile,
global: this.config.get<ToolsConfig>(TOOLS_SECTION),
sessionDisabledTools: this.sessionToolPolicy.disabledTools(),
@ -941,11 +957,6 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
}
return parts.join('\n\n');
}
private readConfiguredCwd(): string | undefined {
const cwd = this.optionsValue.cwd;
return typeof cwd === 'function' ? cwd() : cwd;
}
}
registerScopedService(

View file

@ -24,7 +24,6 @@ import type { PermissionMode } from '#/agent/permissionPolicy/types';
import type { SwarmModeTrigger } from '#/agent/swarm/swarm';
import type { ToolDisclosure, ToolInfo } from '#/tool/toolContract';
import type { ResolvedConfig } from '#/app/config/config';
import type { McpServerConfig } from '#/agent/mcp/config-schema';
import type { ExperimentalFeatureState } from '#/app/flag/flag';
import type { ResumeSessionResult } from '#/agent/replayBuilder/types';
import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata';
@ -65,7 +64,6 @@ export interface CreateSessionPayload {
readonly thinking?: string | undefined;
readonly permission?: PermissionMode | undefined;
readonly metadata?: JsonObject | undefined;
readonly mcpServers?: Readonly<Record<string, McpServerConfig>>;
readonly additionalDirs?: readonly string[];
readonly client?: ClientTelemetryInfo | undefined;
}
@ -80,7 +78,6 @@ export interface ArchiveSessionPayload {
export interface ResumeSessionPayload {
readonly sessionId: string;
readonly mcpServers?: Readonly<Record<string, McpServerConfig>>;
readonly additionalDirs?: readonly string[];
}
@ -257,18 +254,6 @@ export interface GetPluginInfoPayload {
export type ReloadPluginsResult = ReloadSummary;
export type { PluginSummary, PluginInfo };
export interface AddAdditionalDirPayload {
readonly path: string;
readonly persist: boolean;
}
export interface AddAdditionalDirResult {
readonly additionalDirs: readonly string[];
readonly projectRoot: string;
readonly configPath: string;
readonly persisted: boolean;
}
export interface RenameSessionPayload {
readonly title: string;
}
@ -336,7 +321,6 @@ export interface SessionAPI extends AgentAPIWithId {
reconnectMcpServer: (payload: ReconnectMcpServerPayload) => void;
generateAgentsMd: (payload: EmptyPayload) => void;
getSessionWarnings: (payload: EmptyPayload) => readonly SessionWarning[];
addAdditionalDir: (payload: AddAdditionalDirPayload) => AddAdditionalDirResult;
}
type SessionAPIWithId = WithSessionId<SessionAPI>;

View file

@ -3,7 +3,8 @@
*
* Owns the activation pass that turns the module-level `registerAgentToolService`
* contributions (`toolRegistry`, L3) into entries of the per-agent runtime
* registry: a contribution activates only when its `when` predicate holds
* registry: a contribution activates only when its `when` predicate holds,
* the workspace os-level veto (`sessionToolPolicyGate`) does not disable it,
* and its declared `name` is allowed by the bound Profile's tool policy
* (`profile`, L4). `AgentLifecycleService.create` awaits one activation pass
* after restore and profile binding, so an Agent's tools reflect the Profile

View file

@ -2,7 +2,8 @@
* `toolActivation` domain (L4) `IAgentToolActivationService` implementation.
*
* Iterates the `toolRegistry` contribution table and, for each entry allowed
* by the bound Profile's tool policy (`profile`), resolves the Agent-scope
* by the workspace os-level veto (the seeded `sessionToolPolicyGate`) AND
* the bound Profile's tool policy (`profile`), resolves the Agent-scope
* service through the container nothing constructs the tool before this
* `accessor.get` and registers the real instance into the runtime
* registry.
@ -30,6 +31,7 @@ import { IAgentProfileService } from '#/agent/profile/profile';
import { isToolActive } from '#/agent/toolPolicy/evaluate';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { getAgentToolContributions } from '#/agent/toolRegistry/toolContribution';
import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate';
import { IAgentToolActivationService } from './toolActivation';
@ -40,6 +42,7 @@ export class AgentToolActivationService extends Disposable implements IAgentTool
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService,
@IAgentProfileService private readonly profile: IAgentProfileService,
@ISessionToolPolicyGate private readonly toolPolicyGate: ISessionToolPolicyGate,
@IEventBus eventBus: IEventBus,
) {
super();
@ -53,10 +56,15 @@ export class AgentToolActivationService extends Disposable implements IAgentTool
activate(): Promise<void> {
const data = this.profile.data();
const policy = { tools: data.activeToolNames, disallowedTools: data.disallowedTools };
const workspaceVeto = { disallowedTools: this.toolPolicyGate.disabledTools };
this.instantiationService.invokeFunction((accessor) => {
for (const { id, options } of getAgentToolContributions()) {
const source = options.source ?? 'builtin';
if (this.toolRegistry.resolve(options.name) !== undefined) continue;
// The workspace (os-level) veto outranks the profile: a disabled tool
// never activates, so it never reaches the registry (and therefore
// the schema) at all.
if (!isToolActive(workspaceVeto, options.name, source)) continue;
if (!isToolActive(policy, options.name, source)) continue;
if (options.when !== undefined && !options.when(accessor)) continue;
const tool = accessor.get(id);

View file

@ -3,8 +3,9 @@
*
* Applies allowlists and denylists with builtin/MCP matching semantics shared
* by Agent authorization, profile prompt construction, and child-agent setup.
* `isToolActiveComposed` intersects the three policy layers (profile, global
* `[tools]` config, Session denylist) so every consumer evaluates the same
* `isToolActiveComposed` intersects the policy layers (workspace os-level
* veto, profile, global `[tools]` config, Session denylist the workspace
* veto first, outranking the rest) so every consumer evaluates the same
* combination instead of re-implementing it. An empty/absent global `enabled`
* list means unconstrained an explicit empty list must never disable
* everything.
@ -58,6 +59,12 @@ export interface GlobalToolsPolicy {
}
export interface ToolPolicyLayers {
/**
* The workspace (os-level) veto: tools the runtime / workspace disables.
* Evaluated FIRST it outranks every other layer, so a workspace-disabled
* tool is inactive no matter what profile, config, or session layers say.
*/
readonly workspaceDisabledTools?: readonly string[];
readonly profile: ToolActivationPolicy;
readonly global?: GlobalToolsPolicy;
readonly sessionDisabledTools?: readonly string[];
@ -69,6 +76,7 @@ export function isToolActiveComposed(
source: ToolSource = 'builtin',
): boolean {
return (
isToolActive({ disallowedTools: layers.workspaceDisabledTools }, name, source) &&
isToolActive(layers.profile, name, source) &&
isToolActive(
{

View file

@ -1,12 +1,14 @@
/**
* `toolPolicy` domain (L4) Agent-scope tool authorization service.
*
* Intersects the bound profile policy, global `[tools]` configuration, and
* Session denylist (composed by `isToolActiveComposed` in `./evaluate`), and
* installs the resulting authorization check into the L3 executor preflight so
* direct tool calls cannot bypass schema filtering. Disclosure entries retain
* their implicit availability when a profile allowlist omits them, while
* explicit deny layers still apply.
* Intersects the workspace os-level veto (the seeded `sessionToolPolicyGate`,
* which outranks everything below it), the bound profile policy, global
* `[tools]` configuration, and Session denylist (composed by
* `isToolActiveComposed` in `./evaluate`), and installs the resulting
* authorization check into the L3 executor preflight so direct tool calls
* cannot bypass schema filtering. Disclosure entries retain their implicit
* availability when a profile allowlist omits them, while explicit deny
* layers still apply.
*/
import { Disposable } from '#/_base/di/lifecycle';
@ -16,6 +18,7 @@ import { TOOLS_SECTION, type ToolsConfig } from './configSection';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import { IConfigService } from '#/app/config/config';
import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy';
import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate';
import { SELECT_TOOLS_TOOL_NAME } from '#/agent/toolSelect/toolSelect';
import type { ToolSource } from '#/tool/toolContract';
@ -29,6 +32,7 @@ export class AgentToolPolicyService extends Disposable implements IAgentToolPoli
@IAgentProfileService private readonly profile: IAgentProfileService,
@IConfigService private readonly config: IConfigService,
@ISessionToolPolicy private readonly sessionToolPolicy: ISessionToolPolicy,
@ISessionToolPolicyGate private readonly toolPolicyGate: ISessionToolPolicyGate,
@IAgentToolExecutorService toolExecutor: IAgentToolExecutorService,
) {
super();
@ -61,6 +65,7 @@ export class AgentToolPolicyService extends Disposable implements IAgentToolPoli
const profile = this.profile.data();
return isToolActiveComposed(
{
workspaceDisabledTools: this.toolPolicyGate.disabledTools,
profile: { disallowedTools: profile.disallowedTools },
global: this.config.get<ToolsConfig>(TOOLS_SECTION),
sessionDisabledTools: this.sessionToolPolicy.disabledTools(),
@ -77,6 +82,7 @@ export class AgentToolPolicyService extends Disposable implements IAgentToolPoli
): boolean {
return isToolActiveComposed(
{
workspaceDisabledTools: this.toolPolicyGate.disabledTools,
profile,
global: this.config.get<ToolsConfig>(TOOLS_SECTION),
sessionDisabledTools: this.sessionToolPolicy.disabledTools(),

View file

@ -276,7 +276,6 @@ export class SubagentTool implements ISubagentTool {
profile: profile.name,
model: binding.model,
thinking: binding.thinking,
cwd: own.cwd,
},
labels: subagentLabels(this.callerAgentId),
});

View file

@ -1,66 +0,0 @@
/**
* `agentFileCatalog` domain (L3) agent-profile source contract.
*
* `IAgentProfileSource` is the producer half of the agent-file subsystem: each
* source loads an `AgentProfileContribution` and advertises a `priority` so the
* Session catalog can ordered-merge contributions (higher priority wins name
* collisions). Mirrors `skillCatalog/skillSource`, with one deliberate
* deviation: `explicit` outranks every other source (in the skill system it
* aliases `user`) because `--agent-file` is a one-shot command-line intent that
* must always win. Concrete sources (user at App scope; plugin / project /
* extra / explicit at Session scope) each bind their own DI token extending
* this contract.
*
* A source may mark `load()` failures as `fatal`: the Session catalog lets
* them propagate into `ready` so awaiters see the error (`explicit` does
* `--agent-file` is an explicit user intent that must not be silently
* dropped); without it a failure degrades to a warning and keeps any
* previously loaded contribution, because directory sources must never poison
* a session over a transient fs error. `profilesFromDiscovery` binds each
* profile's `${base_prompt}` placeholder lazily at render time, so it always
* reflects the effective default profile (builtin, or the `SYSTEM.md`
* override) rather than any file-based definition.
*/
import type { Event } from '#/_base/event';
import type {
AgentProfile,
AgentProfileContext,
} from '#/app/agentProfileCatalog/agentProfileCatalog';
import { agentProfileFromFile } from './agentProfileFromFile';
import type { AgentFileDiscoveryResult, SkippedAgentFile } from './types';
export interface AgentProfileContribution {
readonly profiles: readonly AgentProfile[];
readonly skipped?: readonly SkippedAgentFile[];
readonly scannedRoots?: readonly string[];
}
export const AGENT_PROFILE_SOURCE_PRIORITY = {
plugin: 5,
user: 10,
extra: 20,
project: 30,
explicit: 40,
} as const;
export interface IAgentProfileSource {
readonly _serviceBrand: undefined;
readonly id: string;
readonly priority: number;
readonly onDidChange?: Event<void>;
readonly fatal?: boolean;
load(): Promise<AgentProfileContribution>;
}
export function profilesFromDiscovery(
result: AgentFileDiscoveryResult,
basePrompt: (context: AgentProfileContext) => string,
): AgentProfileContribution {
return {
profiles: result.agents.map((definition) => agentProfileFromFile(definition, basePrompt)),
skipped: result.skipped,
scannedRoots: result.scannedRoots,
};
}

View file

@ -1,94 +0,0 @@
/**
* `agentFileCatalog` domain (L3) user `IAgentProfileSource` producer.
*
* Discovers user agent profiles through `bootstrap` home paths and `hostFs`,
* reports skipped files through `log`, and appends the `<home>/SYSTEM.md`
* prompt-override profile (synthesized against the builtin default from the
* App profile catalog) after the scanned profiles so it wins same-name
* collisions within this contribution. Also exposes the effective default
* profile the `SYSTEM.md` override when present, else the builtin default,
* refreshed on each `load()` pass so every agent-file source can back
* `${base_prompt}` with it. Bound at App scope.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { ILogService } from '#/_base/log/log';
import {
IAgentProfileCatalogService,
type AgentProfile,
} from '#/app/agentProfileCatalog/agentProfileCatalog';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { discoverAgentFiles } from './agentFileDiscovery';
import {
AGENT_PROFILE_SOURCE_PRIORITY,
profilesFromDiscovery,
type AgentProfileContribution,
type IAgentProfileSource,
} from './agentProfileSource';
import { userAgentRoots } from './agentRoots';
import { loadSystemMdProfile } from './systemFile';
export interface IUserFileAgentSource extends IAgentProfileSource {
readonly _serviceBrand: undefined;
getDefaultProfile(): AgentProfile;
}
export const IUserFileAgentSource: ServiceIdentifier<IUserFileAgentSource> =
createDecorator<IUserFileAgentSource>('userFileAgentSource');
export class UserFileAgentSource implements IUserFileAgentSource {
declare readonly _serviceBrand: undefined;
readonly id = 'user';
readonly priority = AGENT_PROFILE_SOURCE_PRIORITY.user;
private defaultProfile: AgentProfile;
constructor(
@IBootstrapService private readonly bootstrap: IBootstrapService,
@IHostFileSystem private readonly fs: IHostFileSystem,
@ILogService private readonly log: ILogService,
@IAgentProfileCatalogService private readonly builtin: IAgentProfileCatalogService,
) {
this.defaultProfile = builtin.getDefault();
}
getDefaultProfile(): AgentProfile {
return this.defaultProfile;
}
async load(): Promise<AgentProfileContribution> {
const roots = await userAgentRoots(
this.fs,
this.bootstrap.homeDir,
this.bootstrap.osHomeDir,
(message, error) => {
this.log.warn(message, error);
},
);
const systemMd = await loadSystemMdProfile(
this.fs,
this.bootstrap.homeDir,
this.builtin.getDefault(),
(message) => this.log.warn(message),
);
this.defaultProfile = systemMd ?? this.builtin.getDefault();
const contribution = profilesFromDiscovery(
await discoverAgentFiles(this.fs, roots, (message) => this.log.warn(message)),
(context) => this.defaultProfile.systemPrompt(context),
);
if (systemMd === undefined) return contribution;
return { ...contribution, profiles: [...contribution.profiles, systemMd] };
}
}
registerScopedService(
LifecycleScope.App,
IUserFileAgentSource,
UserFileAgentSource,
ScopeActivation.OnScopeCreated,
'agentFileCatalog',
);

View file

@ -1,6 +1,6 @@
/**
* `agentProfileCatalog` domain (L3) App-scope registry of named agent
* profiles.
* `agentProfileCatalog` domain (L3) the agent-profile domain types and the
* App-scope extension point (`IAgentProfileRegistry`).
*
* A profile is "how an Agent runs": the full system prompt it renders for a
* given context, the tool set it may use, plus optional per-invocation and
@ -22,17 +22,17 @@
* an allowlist of subagent profile names the agent may delegate to
* (`undefined` = any type).
*
* Profiles are contributed at module load via `registerAgentProfile(...)`, the
* same "import = register" pattern used by `registerAgentToolService` and
* `registerConfigSection`. `AgentProfileCatalogService` consumes the accumulated
* contributions on construction and exposes `get(name)` / `getDefault()` /
* `list()` to callers (the `Agent` tool, the swarm scheduler, and the per-agent
* profile binding). Contributions are keyed by `name`; a later-registered
* profile with the same name overrides an earlier one.
* Profiles reach agents through the Contribution / Registry / Catalog
* extension point: loaders (builtin code contributions via
* `registerAgentProfile(...)`, plugin / user file scans at App scope,
* workspace / extra / explicit file scans at Workspace scope) register
* `AgentProfileContribution`s into the App-scope `IAgentProfileRegistry`,
* keyed by source id; the Session-scope `ISessionAgentProfileCatalog`
* projects the registry into the merged, name-deduped read view that
* consumers (the `Agent` tool, the swarm scheduler, the per-agent profile
* binding) resolve profiles through.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { ILogger } from '#/_base/log/log';
import type { ISessionProcessRunner } from '#/session/process/processRunner';
@ -82,14 +82,3 @@ export interface AgentProfile {
readonly promptPrefix?: (ctx: AgentProfilePromptPrefixContext) => Promise<string>;
readonly summaryPolicy?: AgentProfileSummaryPolicy;
}
export interface IAgentProfileCatalogService {
readonly _serviceBrand: undefined;
get(name: string): AgentProfile | undefined;
getDefault(): AgentProfile;
list(): readonly AgentProfile[];
}
export const IAgentProfileCatalogService: ServiceIdentifier<IAgentProfileCatalogService> =
createDecorator<IAgentProfileCatalogService>('agentProfileCatalogService');

View file

@ -1,56 +0,0 @@
/**
* `agentProfileCatalog` domain (L3) `IAgentProfileCatalogService` impl.
*
* Snapshots the module-level contributions on construction. Register-after-
* construction is not supported: like `IAgentToolRegistryService`, the
* expectation is that contributions accumulate at import time before the
* container resolves the service. `getDefault()` throws a plain `Error` when
* the builtin default profile is missing that is a programming-time
* invariant violation, not a request failure, so it does not warrant a wire
* error code.
*/
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import type { AgentProfile } from './agentProfileCatalog';
import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from './agentProfileCatalog';
import { getAgentProfileContributions } from './contribution';
export class AgentProfileCatalogService implements IAgentProfileCatalogService {
declare readonly _serviceBrand: undefined;
private readonly byName: Map<string, AgentProfile>;
private readonly ordered: readonly AgentProfile[];
constructor() {
const contributions = getAgentProfileContributions();
this.ordered = [...contributions];
this.byName = new Map(this.ordered.map((def) => [def.name, def]));
}
get(name: string): AgentProfile | undefined {
return this.byName.get(name);
}
getDefault(): AgentProfile {
const profile = this.byName.get(DEFAULT_AGENT_PROFILE_NAME);
if (profile === undefined) {
throw new Error(
`Default agent profile "${DEFAULT_AGENT_PROFILE_NAME}" is not registered`,
);
}
return profile;
}
list(): readonly AgentProfile[] {
return this.ordered;
}
}
registerScopedService(
LifecycleScope.App,
IAgentProfileCatalogService,
AgentProfileCatalogService,
ScopeActivation.OnScopeCreated,
'agentProfileCatalog',
);

View file

@ -0,0 +1,42 @@
/**
* `agentProfileCatalog` domain (L3) the agent-profile Contribution shape and
* source priorities.
*
* `AgentProfileContribution` is the Contribution of the agent-profile
* extension point: the plain data structure a loader registers into the
* App-scope `IAgentProfileRegistry` under its source id. It is pure payload
* the source id and priority are registration metadata passed to `register`,
* never part of the contribution. Name-level dedup is NOT done here or in the
* registry; it is the Session catalog's projection job. The shape lives beside
* the registry (not beside any loader) because it is the registry's contract
* with every contributor, whatever scope the contributor runs in.
*
* `AGENT_PROFILE_SOURCE_PRIORITY` orders the sources for that projection
* (higher wins name collisions), with one deliberate deviation from the skill
* system: `explicit` outranks every other source (in the skill system it
* aliases `user`) because `--agent-file` is a one-shot command-line intent
* that must always win.
*/
import type { AgentProfile } from './agentProfileCatalog';
/** A file a discovery pass could not parse, paired with the reason. */
export interface SkippedAgentFile {
readonly path: string;
readonly reason: string;
}
export interface AgentProfileContribution {
readonly profiles: readonly AgentProfile[];
readonly skipped?: readonly SkippedAgentFile[];
readonly scannedRoots?: readonly string[];
}
export const AGENT_PROFILE_SOURCE_PRIORITY = {
builtin: 0,
plugin: 5,
user: 10,
extra: 20,
workspace: 30,
explicit: 40,
} as const;

View file

@ -0,0 +1,64 @@
/**
* `agentProfileCatalog` domain (L3) `IAgentProfileRegistry` contract.
*
* The Registry of the Contribution / Registry / Catalog extension-point
* pattern for agent profiles. A contribution is a plain data structure
* (`AgentProfileContribution`) offered by a loader; the registry stores at
* most one contribution per (`sourceId`, `workspaceKey`) pair re-registering
* replaces the previous entry, which is the only dedup this layer performs.
* Name-level dedup, priority ordering, and the builtin-override rule are the
* Catalog's projection job (`ISessionAgentProfileCatalog`), never the
* registry's.
*
* Bound at App scope so contributors from ANY scope can register: App loaders
* (builtin / plugin / user) register global contributions (`workspaceKey`
* absent), while each Workspace-scope loader registers its workspace-local
* contribution tagged with the handler's `workspaceKey`, and multiple
* workspaces never collide. `register` returns a handle whose `dispose`
* unregisters but only the entry it registered, so a stale handle can never
* evict a newer re-registration. Every mutation fires `onDidChange` with the
* affected (sourceId, workspaceKey) so session catalogs can re-project.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { IDisposable } from '#/_base/di/lifecycle';
import type { Event } from '#/_base/event';
import type { AgentProfileContribution } from './agentProfileContribution';
export interface AgentProfileRegistration {
readonly sourceId: string;
readonly priority: number;
/** Absent = global contribution; present = specific to one workspace handler. */
readonly workspaceKey?: string;
readonly contribution: AgentProfileContribution;
}
export interface AgentProfileRegistryChange {
readonly sourceId: string;
readonly workspaceKey?: string;
}
export interface RegisterAgentProfileOptions {
readonly priority?: number;
/** Tag a workspace-local contribution; global contributors omit it. */
readonly workspaceKey?: string;
}
export interface IAgentProfileRegistry {
readonly _serviceBrand: undefined;
readonly onDidChange: Event<AgentProfileRegistryChange>;
register(
sourceId: string,
contribution: AgentProfileContribution,
options?: RegisterAgentProfileOptions,
): IDisposable;
unregister(sourceId: string, workspaceKey?: string): void;
entries(): readonly AgentProfileRegistration[];
}
export const IAgentProfileRegistry: ServiceIdentifier<IAgentProfileRegistry> =
createDecorator<IAgentProfileRegistry>('agentProfileRegistry');

View file

@ -0,0 +1,81 @@
/**
* `agentProfileCatalog` domain (L3) `IAgentProfileRegistry` impl.
*
* App-scope singleton backed by the generic `ContributionRegistry`: storage
* keys encode the (sourceId, workspaceKey) pair so a workspace-local source id
* (`workspace`, `extra`, `explicit`) coexists across handlers, while global
* sources (`builtin`, `plugin`, `user`) register once. The registry is pure
* storage merging, name dedup, and override rules live in the Session-scope
* catalog projection.
*/
import { ContributionRegistry } from '#/_base/contribution/registry';
import { Disposable, type IDisposable } from '#/_base/di/lifecycle';
import type { Event } from '#/_base/event';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import type { AgentProfileContribution } from './agentProfileContribution';
import type {
AgentProfileRegistration,
AgentProfileRegistryChange,
IAgentProfileRegistry,
RegisterAgentProfileOptions,
} from './agentProfileRegistry';
import { IAgentProfileRegistry as IAgentProfileRegistryDecorator } from './agentProfileRegistry';
function encodeKey(sourceId: string, workspaceKey: string | undefined): string {
return JSON.stringify([sourceId, workspaceKey ?? null]);
}
function decodeKey(key: string): AgentProfileRegistryChange {
const [sourceId, workspaceKey] = JSON.parse(key) as [string, string | null];
return { sourceId, workspaceKey: workspaceKey ?? undefined };
}
export class AgentProfileRegistryService
extends Disposable
implements IAgentProfileRegistry
{
declare readonly _serviceBrand: undefined;
private readonly registry = this._register(
new ContributionRegistry<AgentProfileRegistration>(),
);
readonly onDidChange: Event<AgentProfileRegistryChange> = (listener, thisArg, disposables) =>
this.registry.onDidChange(
(key) => listener.call(thisArg, decodeKey(key)),
undefined,
disposables,
);
register(
sourceId: string,
contribution: AgentProfileContribution,
options?: RegisterAgentProfileOptions,
): IDisposable {
const registration: AgentProfileRegistration = {
sourceId,
priority: options?.priority ?? 0,
workspaceKey: options?.workspaceKey,
contribution,
};
return this.registry.register(encodeKey(sourceId, options?.workspaceKey), registration);
}
unregister(sourceId: string, workspaceKey?: string): void {
this.registry.unregister(encodeKey(sourceId, workspaceKey));
}
entries(): readonly AgentProfileRegistration[] {
return this.registry.entries().map((entry) => entry.contribution);
}
}
registerScopedService(
LifecycleScope.App,
IAgentProfileRegistryDecorator,
AgentProfileRegistryService,
ScopeActivation.OnScopeCreated,
'agentProfileCatalog',
);

View file

@ -0,0 +1,28 @@
/**
* `agentProfileCatalog` domain (L3) `IBuiltinAgentProfileLoader` contract.
*
* The builtin loader of the agent-profile extension point: owns the global
* `builtin` contribution (priority 0) in the App-scope `IAgentProfileRegistry`
* the code-defined profiles accumulated at module load via
* `registerAgentProfile(...)`. Also exposes the static `get` / `getDefault` /
* `list` read view for loader-time consumers that need the builtin default
* before any session catalog exists (`UserAgentProfileLoader`'s `SYSTEM.md`
* synthesis). App-scoped.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { AgentProfile } from './agentProfileCatalog';
export const BUILTIN_AGENT_PROFILE_SOURCE_ID = 'builtin';
export interface IBuiltinAgentProfileLoader {
readonly _serviceBrand: undefined;
get(name: string): AgentProfile | undefined;
getDefault(): AgentProfile;
list(): readonly AgentProfile[];
}
export const IBuiltinAgentProfileLoader: ServiceIdentifier<IBuiltinAgentProfileLoader> =
createDecorator<IBuiltinAgentProfileLoader>('builtinAgentProfileLoader');

View file

@ -0,0 +1,75 @@
/**
* `agentProfileCatalog` domain (L3) `IBuiltinAgentProfileLoader` implementation.
*
* Snapshots the module-level contributions (`registerAgentProfile`, the
* "import = register" pattern) on construction and registers them into
* `IAgentProfileRegistry`. Register-after-construction is not supported: like
* `IAgentToolRegistryService`, contributions are expected to accumulate at
* import time before the container resolves the service. `getDefault()`
* throws a plain `Error` when the builtin default profile is missing a
* programming-time invariant violation, not a request failure. Bound at App
* scope.
*/
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import type { AgentProfile } from './agentProfileCatalog';
import { DEFAULT_AGENT_PROFILE_NAME } from './agentProfileCatalog';
import { AGENT_PROFILE_SOURCE_PRIORITY } from './agentProfileContribution';
import { IAgentProfileRegistry } from './agentProfileRegistry';
import {
BUILTIN_AGENT_PROFILE_SOURCE_ID,
IBuiltinAgentProfileLoader,
} from './builtinAgentProfileLoader';
import { getAgentProfileContributions } from './contribution';
export class BuiltinAgentProfileLoaderService
extends Disposable
implements IBuiltinAgentProfileLoader
{
declare readonly _serviceBrand: undefined;
private readonly byName: Map<string, AgentProfile>;
private readonly ordered: readonly AgentProfile[];
constructor(@IAgentProfileRegistry registry: IAgentProfileRegistry) {
super();
const contributions = getAgentProfileContributions();
this.ordered = [...contributions];
this.byName = new Map(this.ordered.map((def) => [def.name, def]));
this._register(
registry.register(
BUILTIN_AGENT_PROFILE_SOURCE_ID,
{ profiles: this.ordered },
{ priority: AGENT_PROFILE_SOURCE_PRIORITY.builtin },
),
);
}
get(name: string): AgentProfile | undefined {
return this.byName.get(name);
}
getDefault(): AgentProfile {
const profile = this.byName.get(DEFAULT_AGENT_PROFILE_NAME);
if (profile === undefined) {
throw new Error(
`Default agent profile "${DEFAULT_AGENT_PROFILE_NAME}" is not registered`,
);
}
return profile;
}
list(): readonly AgentProfile[] {
return this.ordered;
}
}
registerScopedService(
LifecycleScope.App,
IBuiltinAgentProfileLoader,
BuiltinAgentProfileLoaderService,
ScopeActivation.OnScopeCreated,
'agentProfileCatalog',
);

View file

@ -71,10 +71,6 @@ export interface IBootstrapService {
readonly logsDir: string;
getEnv(name: string): string | undefined;
scope(name: PersistenceScopeName): string;
sessionScope(workspaceId: string, sessionId: string): string;
agentScope(workspaceId: string, sessionId: string, agentId: string): string;
sessionDir(workspaceId: string, sessionId: string): string;
agentHomedir(workspaceId: string, sessionId: string, agentId: string): string;
readonly configKey: string;
}

View file

@ -2,13 +2,11 @@
* `bootstrap` domain (L1) `IBootstrapService` implementation.
*
* Holds the resolved startup snapshot from the seeded `IBootstrapOptions` and
* exposes the host facts, app path layout, and semantic scope mapping. All
* `scope*(...)` methods and `configKey` are computed once at construction so
* business code can read them synchronously. Path fields (`homeDir` / `*Dir` /
* `configPath`) are kept alongside for now to ease migration, but new business
* code should prefer `scope(name)` / `sessionScope(...)` / `agentScope(...)`
* only the file-only accessors (`sessionDir` / `agentHomedir`) still hand out
* absolute paths, for the small number of legacy APIs that need them.
* exposes the host facts, app path layout, and top-level scope mapping. All
* `scope(name)` values and `configKey` are computed once at construction so
* business code can read them synchronously. Session/agent persistence
* addressing is NOT here it derives from the workspace handler's
* persistence scope (`workspaceHandler` addressing).
*
* Bound at App scope.
*/
@ -62,7 +60,7 @@ export class BootstrapService implements IBootstrapService {
this.configKey = basename(options.configPath);
this.scopes = {
config: '',
sessions: relative(options.homeDir, this.sessionsDir),
sessions: relative(options.homeDir, join(options.homeDir, 'sessions')),
blobs: relative(options.homeDir, this.blobsDir),
store: relative(options.homeDir, this.storeDir),
logs: relative(options.homeDir, this.logsDir),
@ -79,22 +77,6 @@ export class BootstrapService implements IBootstrapService {
scope(name: PersistenceScopeName): string {
return this.scopes[name];
}
sessionScope(workspaceId: string, sessionId: string): string {
return join(this.scopes.sessions, workspaceId, sessionId);
}
agentScope(workspaceId: string, sessionId: string, agentId: string): string {
return join(this.sessionScope(workspaceId, sessionId), 'agents', agentId);
}
sessionDir(workspaceId: string, sessionId: string): string {
return join(this.homeDir, this.sessionScope(workspaceId, sessionId));
}
agentHomedir(workspaceId: string, sessionId: string, agentId: string): string {
return join(this.homeDir, this.agentScope(workspaceId, sessionId, agentId));
}
}
registerScopedService(LifecycleScope.App, IBootstrapService, BootstrapService, ScopeActivation.OnScopeCreated, 'bootstrap');

View file

@ -203,6 +203,19 @@ export interface IConfigService {
getAll(): ResolvedConfig;
set(domain: string, patch: unknown, target?: ConfigTarget): Promise<void>;
replace(domain: string, value: unknown, target?: ConfigTarget): Promise<void>;
/**
* Replaces several sections in ONE state transition: every domain's raw
* value is updated first, the document is persisted with a single disk
* write, and the effective view is rebuilt once change events fire only
* after all domains have taken effect, so a reader can never observe a
* half-applied multi-section write. A domain mapped to `undefined` is
* cleared (same as `replace(domain, undefined)`). Domain application order
* follows the `sections` key order.
*/
replaceSections(
sections: Readonly<Record<string, unknown>>,
target?: ConfigTarget,
): Promise<void>;
reload(): Promise<void>;
diagnostics(): readonly ConfigDiagnostic[];
}

View file

@ -354,6 +354,43 @@ export class ConfigService extends Disposable implements IConfigService {
});
}
async replaceSections(
sections: Readonly<Record<string, unknown>>,
target: ConfigTarget = ConfigTarget.User,
): Promise<void> {
await this.ready;
const domains = Object.keys(sections);
if (domains.length === 0) return;
if (target === ConfigTarget.Memory) {
const staged: ResolvedConfig = { ...this.memory };
for (const domain of domains) {
const value = sections[domain];
if (value === undefined) {
delete staged[domain];
} else {
staged[domain] = this.registry.validate(domain, value);
}
}
this.memory = staged;
this.commit('set', domains);
return;
}
await this.enqueueStateTransition(async () => {
const staged: ResolvedConfig = { ...this.raw };
for (const domain of domains) {
const stripped = this.stripEnv(domain, sections[domain]);
if (stripped === undefined) {
delete staged[domain];
} else {
staged[domain] = this.registry.validate(domain, stripped);
}
}
this.raw = staged;
await this.persistDomains(domains);
this.rebuildEffective('set', domains);
});
}
private stripEnv(domain: string, value: unknown): unknown {
let result = value;
const section = this.registry.getSection(domain);
@ -565,7 +602,13 @@ export class ConfigService extends Disposable implements IConfigService {
}
private async persist(domain: string): Promise<void> {
applySectionToToml(this.rawSnake, domain, this.raw[domain], this.registry);
await this.persistDomains([domain]);
}
private async persistDomains(domains: readonly string[]): Promise<void> {
for (const domain of domains) {
applySectionToToml(this.rawSnake, domain, this.raw[domain], this.registry);
}
await this.documentStore.set(CONFIG_SCOPE, this.configKey, this.rawSnake);
}
}

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

@ -1,7 +1,8 @@
/**
* `gateway` domain (L7) `IRestGateway` / `IWSGateway` implementations.
*
* Owns the REST/WS entry points; resolves sessions through `sessionLifecycle`,
* Owns the REST/WS entry points; resolves sessions through the live handler
* registry (`workspaceLifecycle` the handler's `IWorkspaceHandlerService`),
* agents through `agentLifecycle`, drives turns through `prompt` / `loop`,
* and flushes logs through `log`. Bound at App scope.
*
@ -18,7 +19,8 @@ import {
} from '#/_base/di/scope';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { ILogService } from '#/_base/log/log';
import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle';
import { IWorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLifecycle';
import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler';
import { IAgentPromptService } from '#/agent/prompt/prompt';
import { IAgentLoopService } from '#/agent/loop/loop';
@ -28,12 +30,12 @@ export class RestGateway implements IRestGateway {
declare readonly _serviceBrand: undefined;
constructor(
@ISessionLifecycleService private readonly sessions: ISessionLifecycleService,
@IWorkspaceLifecycleService private readonly workspaceLifecycle: IWorkspaceLifecycleService,
@ILogService private readonly log: ILogService,
) { }
private agent(sessionId: string, agentId: string): IAgentScopeHandle {
const session = this.sessions.get(sessionId);
const session = this.liveSession(sessionId);
if (session === undefined) throw new Error(`unknown session '${sessionId}'`);
const agents = session.accessor.get(IAgentLifecycleService);
const agent = agents.get(agentId);
@ -41,6 +43,14 @@ export class RestGateway implements IRestGateway {
return agent;
}
private liveSession(sessionId: string) {
for (const handler of this.workspaceLifecycle.handlers.list()) {
const handle = handler.accessor.get(IWorkspaceHandlerService).get(sessionId);
if (handle !== undefined) return handle;
}
return undefined;
}
async prompt(
sessionId: string,
agentId: string,
@ -78,11 +88,11 @@ export class RestGateway implements IRestGateway {
return Promise.resolve();
}
getStatus(sessionId: string): Promise<unknown> {
return Promise.resolve(this.sessions.get(sessionId) !== undefined);
return Promise.resolve(this.liveSession(sessionId) !== undefined);
}
async flushLogs(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
const session = this.liveSession(sessionId);
if (session === undefined) return;
await session.accessor.get(ILogService).flush();
}
@ -96,10 +106,6 @@ export class WSGateway implements IWSGateway {
declare readonly _serviceBrand: undefined;
private readonly connections = new Set<string>();
constructor(
@ISessionLifecycleService _sessions: ISessionLifecycleService,
) { }
connect(connectionId: string): void {
this.connections.add(connectionId);
}

View file

@ -2,17 +2,23 @@
* `git` domain (L1) git integration for a repository on the local disk.
*
* Defines the `IGitService` that runs `git status` / `git diff` (plus `gh pr
* view`) against a repository identified by an absolute `cwd`. App-scoped; it
* spawns `git` / `gh` through the `os/interface` process service rather than a
* Session's execution environment, so it never depends on a Session. Path
* confinement is the caller's responsibility the service receives
* already-resolved absolute `cwd` and repo-relative paths.
* view`) against a repository identified by an absolute `cwd`, and discovers
* the enclosing git work tree of a directory (`findWorkTree`, the DI entry
* point over the pure `workTree` probe). App-scoped; it spawns `git` / `gh`
* through the `os/interface` process service rather than a Session's
* execution environment, so it never depends on a Session. Path confinement
* is the caller's responsibility the service receives already-resolved
* absolute `cwd` and repo-relative paths.
*/
import { z } from 'zod';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { GitWorkTree } from './workTree';
export type { GitWorkTree } from './workTree';
export const fsGitStatusSchema = z.enum([
'clean',
'modified',
@ -71,6 +77,7 @@ export interface IGitService {
status(cwd: string, pathFilter?: ReadonlySet<string>): Promise<FsGitStatusResponse>;
diff(cwd: string, relPath: string, absPath: string): Promise<FsDiffResponse>;
findWorkTree(cwd: string): Promise<GitWorkTree | null>;
}
export const IGitService: ServiceIdentifier<IGitService> =

View file

@ -3,9 +3,8 @@
*
* Parses `git status --porcelain=v1 --branch`, `git diff --numstat`, and
* `gh pr view --json` output into the protocol `FsGitStatusResponse` shape.
* No IO, no DI plain functions so they can be unit-tested directly. Moved
* from `session/sessionFs/fsGit.ts` (originally ported from v1
* `services/fs/fsGit.ts`).
* No IO, no DI plain functions so they can be unit-tested directly.
* Originally ported from v1 `services/fs/fsGit.ts`.
*/
import type { FsGitStatus, FsGitStatusResponse, FsPullRequest } from './git';

View file

@ -2,7 +2,9 @@
* `git` domain (L1) `IGitService` implementation.
*
* Runs `git status` / `git diff` (and `gh pr view`) against a repository on
* the local disk. Process spawning goes through the App-scope
* the local disk, and discovers the enclosing git work tree of a directory
* (`findWorkTree`, delegating to the pure `workTree` probe). Process spawning
* goes through the App-scope
* `IHostProcessService` from `os/interface`, and the single path-existence
* probe in `diff` goes through `IHostFileSystem`; no Node platform API is
* imported directly. Bound at App scope it owns no Session dependency, so
@ -19,6 +21,7 @@ import { IHostProcessService } from '#/os/interface/hostProcess';
import { IGitService } from './git';
import { parseNumstat, parsePorcelain, parsePullRequest } from './gitParsers';
import { findGitWorkTree, type GitWorkTree } from './workTree';
const DIFF_MAX_BYTES = 1_048_576;
@ -123,6 +126,10 @@ export class GitService implements IGitService {
};
}
findWorkTree(cwd: string): Promise<GitWorkTree | null> {
return findGitWorkTree(this.fs, cwd);
}
private async readPullRequest(cwd: string): Promise<FsPullRequest | null> {
const cached = this.pullRequestCache.get(cwd);
const now = Date.now();

View file

@ -0,0 +1,75 @@
/**
* `git` domain (L1) git work-tree discovery.
*
* Walks up from a directory to find the enclosing git work tree: the nearest
* ancestor containing a `.git` entry, either a directory (plain repository)
* or a file holding a `gitdir:` pointer (linked worktree / submodule) whose
* target is resolved into `controlDirPath`. Entries that are neither or
* files without a parseable pointer do not count and the walk continues.
* All filesystem access goes through the os `IHostFileSystem` and paths are
* pathe-normalized (Windows-aware, forward slashes). Pure functions the
* single work-tree probe behind `IGitService.findWorkTree` and the direct
* consumers in `profile` / `mcp`.
*/
import { dirname, isAbsolute, join, normalize } from 'pathe';
import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
export interface GitWorkTree {
/** Ancestor directory that contains the `.git` entry. */
readonly root: string;
/** Path of the `.git` entry itself (`<root>/.git`). */
readonly dotGitPath: string;
/**
* The repository's real git control directory `dotGitPath` for a plain
* repository, the resolved `gitdir:` target for a linked worktree /
* submodule.
*/
readonly controlDirPath: string;
}
export async function findGitWorkTree(
fs: IHostFileSystem,
cwd: string,
): Promise<GitWorkTree | null> {
if (cwd.length === 0 || !isAbsolute(cwd)) return null;
let current = normalize(cwd);
while (true) {
const dotGitPath = join(current, '.git');
const controlDirPath = await probeGitControlDir(fs, dotGitPath, current);
if (controlDirPath !== null) return { root: current, dotGitPath, controlDirPath };
const parent = dirname(current);
if (parent === current) return null;
current = parent;
}
}
async function probeGitControlDir(
fs: IHostFileSystem,
dotGitPath: string,
markerParent: string,
): Promise<string | null> {
try {
const stat = await fs.stat(dotGitPath);
if (stat.isDirectory) return dotGitPath;
if (!stat.isFile) return null;
const content = await fs.readText(dotGitPath);
return parseGitDirPointer(content, markerParent) ?? null;
} catch {
return null;
}
}
function parseGitDirPointer(content: string, markerParent: string): string | undefined {
const stripped = content.codePointAt(0) === 0xfeff ? content.slice(1) : content;
const line = stripped.trimStart().split(/\r?\n/, 1)[0]?.trim();
if (line === undefined || !line.startsWith('gitdir:')) return undefined;
const rawPath = line.slice('gitdir:'.length).trim();
if (rawPath.length === 0) return undefined;
return normalize(isAbsolute(rawPath) ? rawPath : join(markerParent, rawPath));
}

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

@ -3,10 +3,11 @@
*
* Owns the all-provider model refresh: delegates to the shared
* `@moonshot-ai/kimi-code-oauth` orchestrator (managed OAuth + open
* platforms + custom registries), applies the discovered providers/models
* to kosong's in-memory registries (the persistence bridge writes them back
* to config), and publishes `event.model_catalog.changed` on change. Bound
* at App scope.
* platforms + custom registries), writes the discovered providers/models
* into config through ONE atomic `replaceSections` transition (the
* persistence bridge then syncs them into kosong's in-memory registries),
* and publishes `event.model_catalog.changed` on change. Bound at App
* scope.
*
* `modelSource: 'static'` short-circuits refresh: a provider whose effective
* model source is `static` (config-declared, or declared by its vendor
@ -18,14 +19,18 @@
* refresh them nor drop them (or a default model pointing at them).
*
* Two write-path details preserve the legacy semantics exactly:
* - Registry replaces preserve the entries the orchestrator could not see:
* the static exclusion AND the config-file-external entries (the
* env-synthesized `__kimi_env__` slice), which the orchestrator's
* user-value view does not contain.
* - `defaultModel` / `thinking` stay direct `config.replace` writes (like
* the OAuth flows): the env overlay may pin the runtime default to the
* env-synthesized model, and only the config effective view knows that
* the bridge then syncs the effective pointer into the registry.
* - The orchestrator's two-phase host contract (removeProvider, then
* setConfig) is absorbed into a single atomic write: the removal is
* computed in memory only (`shapeWithoutProvider`), because the patch's
* full providers/models records already express it. The runtime
* registries therefore never pass through a halfway-removed state that
* intermediate state was the source of the "provider/model not
* configured" startup race against profile binding.
* - The env-synthesized `__kimi_env__` slice is never written to config:
* it lives in the effective overlay, and the bridge's event-driven sync
* carries it into the registries on its own. `defaultModel` / `thinking`
* also go through config (like the OAuth flows), since the env overlay
* may pin the runtime default and only the config effective view knows.
*
* Credential detection goes through the provider-definition registry
* (`resolveProviderEndpoint` against the provider's config env bag), not a
@ -47,7 +52,7 @@ import { IConfigService } from '#/app/config/config';
import { IEventService } from '#/app/event/event';
import { ModelCatalogErrors } from '#/kosong/model/errors';
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
import { IModelService, type ModelRecord } from '#/kosong/model/model';
import { type ModelRecord } from '#/kosong/model/model';
import {
IProviderService,
type ModelSource,
@ -88,7 +93,6 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
private refreshChain: Promise<unknown> = Promise.resolve();
constructor(
@IModelService private readonly modelService: IModelService,
@IProviderService private readonly providerService: IProviderService,
@IConfigService private readonly config: IConfigService,
@IOAuthService private readonly oauth: IOAuthService,
@ -188,7 +192,7 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
private buildRefreshHost(exclusion: StaticExclusion): RefreshProviderHost {
return {
getConfig: async () => this.readUserConfigShape(exclusion),
removeProvider: (providerId) => this.removeProviderForRefresh(providerId),
removeProvider: (providerId) => this.shapeWithoutProvider(providerId),
setConfig: (patch) => this.applyRefreshPatch(patch, exclusion),
resolveOAuthToken: (providerName, oauthRef) => this.resolveOAuthToken(providerName, oauthRef),
userAgent: this.hostRequestHeaders.headers['User-Agent'],
@ -212,23 +216,16 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
}
/**
* The registry entries the orchestrator's user-value view cannot see (the
* env-synthesized slice): preserved verbatim across every registry
* replace, or a refresh would drop the runtime env model/provider.
* The orchestrator's host contract is two-phase (removeProvider, then
* setConfig) because its original merge-semantics host could not delete
* keys through a patch. This host writes with replace semantics and the
* patch always carries the FULL providers/models records, so the removal
* is already expressed by the patch itself computing it here in memory
* keeps the runtime registries untouched until the single atomic write in
* {@link applyRefreshPatch} (a halfway-removed catalog is what used to
* produce the "provider/model not configured" startup race).
*/
private syntheticProviders(
userProviders: Readonly<Record<string, unknown>>,
): Record<string, ProviderConfig> {
return withoutKeys(this.providerService.list(), userProviders);
}
private syntheticModels(
userModels: Readonly<Record<string, unknown>>,
): Record<string, ModelRecord> {
return withoutKeys(this.modelService.list(), userModels);
}
private async removeProviderForRefresh(providerId: string): Promise<ManagedKimiConfigShape> {
private shapeWithoutProvider(providerId: string): Promise<ManagedKimiConfigShape> {
const current = this.readUserConfigShape();
const providers = current.providers as Record<string, ProviderConfig>;
const restProviders = Object.fromEntries(
@ -238,16 +235,11 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
const restModels = Object.fromEntries(
Object.entries(models).filter(([, record]) => record.provider !== providerId),
);
await this.providerService.replaceAll({
...this.syntheticProviders(providers),
...restProviders,
});
await this.modelService.replaceAll({ ...this.syntheticModels(models), ...restModels });
return {
return Promise.resolve({
...current,
providers: restProviders,
models: restModels,
} as ManagedKimiConfigShape;
} as ManagedKimiConfigShape);
}
private async applyRefreshPatch(
@ -258,56 +250,50 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
this.config.inspect<Record<string, ProviderConfig>>(PROVIDERS_SECTION).userValue ?? {};
const userModels =
this.config.inspect<Record<string, ModelRecord>>(MODELS_SECTION).userValue ?? {};
// All four sections land in ONE config transition: a single disk write,
// then one effective rebuild whose change events synchronously push the
// new records into the kosong registries — no reader can observe a
// half-applied refresh. The env-synthesized slice (`__kimi_env__` etc.)
// is NOT written here: it lives in the effective overlay, and the
// bridge's event-driven sync carries it into the registries on its own.
const sections: Record<string, unknown> = {};
if (patch.providers !== undefined) {
await this.providerService.replaceAll({
...this.syntheticProviders(userProviders),
sections[PROVIDERS_SECTION] = {
...exclusion.providers,
...patch.providers,
});
};
}
if (patch.models !== undefined) {
await this.modelService.replaceAll({
...this.syntheticModels(userModels),
sections[MODELS_SECTION] = {
...exclusion.models,
// The orchestrator's alias shape is a structural superset of
// ModelRecord at runtime (its protocol union additionally allows
// vendor spellings the records never actually carry); the legacy
// config.write path took `unknown`, so cast here.
...(patch.models as Record<string, ModelRecord>),
});
};
}
// The refresh orchestrator always sends all four keys, so key presence is
// the write intent and an explicit `undefined` means CLEAR, not "leave
// alone". `set()` cannot express that — its deepMerge resolves an
// undefined patch back to the base value — so these go through `replace`,
// which deletes the section on undefined. Otherwise a default model (and
// its thinking setting) whose alias the upstream dropped would dangle in
// the user config forever.
// alone" — `replaceSections` deletes the section on undefined. Otherwise
// a default model (and its thinking setting) whose alias the upstream
// dropped would dangle in the user config forever.
//
// Exception: when the user's default points at a statically-sourced model
// the orchestrator could not see, its clamp/restore logic would silently
// clear or re-point the selection (and its thinking) — restore both.
//
// `defaultModel` / `thinking` go through config directly (not the
// registry): the env overlay may pin the runtime default, and only the
// config effective view knows — the bridge syncs the effective pointer
// into the registry afterwards.
const restoreDefault = exclusion.defaultModel !== undefined;
if ('defaultModel' in patch) {
await this.config.replace(
DEFAULT_MODEL_SECTION,
restoreDefault ? exclusion.defaultModel : patch.defaultModel,
);
sections[DEFAULT_MODEL_SECTION] = restoreDefault
? exclusion.defaultModel
: patch.defaultModel;
}
if ('thinking' in patch) {
await this.config.replace(
THINKING_SECTION,
restoreDefault ? exclusion.thinking : patch.thinking,
);
sections[THINKING_SECTION] = restoreDefault ? exclusion.thinking : patch.thinking;
}
// The writes above landed in the registries / config; compute the
// post-patch shape in memory (re-reading config would race the bridge's
// asynchronous persist of the registry changes).
await this.config.replaceSections(sections);
// The write above landed in config (and, through the bridge's synchronous
// event sync, the registries); compute the post-patch shape in memory.
return {
providers:
patch.providers !== undefined

View file

@ -1,5 +1,5 @@
/**
* `mcp` domain (L5) registers MCP timeout preferences into `config`.
* `mcpConfig` domain (L5) registers MCP timeout preferences into `config`.
*
* Owns the global MCP startup and tool-call timeout preferences, including
* their environment bindings and persistence guard. Registered into `config`
@ -10,7 +10,7 @@ import { z } from 'zod';
import { type EnvBindings, envBindings, stripEnvBoundFields } from '#/app/config/config';
import { registerConfigSection } from '#/app/config/configSectionContributions';
import { MAX_MCP_TIMEOUT_MS, McpTimeoutMsSchema } from './config-schema';
import { MAX_MCP_TIMEOUT_MS, McpTimeoutMsSchema } from '#/mcpCore/config-schema';
export const MCP_SECTION = 'mcp';

View file

@ -0,0 +1,80 @@
/**
* `mcpConfig` domain (L5) `IMcpOAuthStore`, the App-scope persistence
* adapter for MCP OAuth credentials.
*
* Implements the `mcp` domain's `McpOAuthStore` port over the `persistence`
* access-pattern store (`IAtomicDocumentStore`) under the `credentials/mcp`
* scope (`<homeDir>/credentials/mcp/<key>-*.json`). One App-scope instance is
* shared by every workspace handler's `McpOAuthService`, replacing the
* per-handler stores they used to build ad hoc; the on-disk layout is
* unchanged, so credentials stay shared with out-of-engine readers (the
* node-sdk global OAuth surface). The {@link createMcpOAuthStore} factory
* remains exported for those out-of-engine callers, which run an
* `McpOAuthService` outside the DI container.
*
* Read semantics: missing or corrupt JSON resolves to `undefined` (never
* throws). The provider treats `undefined` as "not stored".
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import type { McpOAuthStore } from '#/mcpCore/oauth/store';
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
export interface IMcpOAuthStore extends McpOAuthStore {
readonly _serviceBrand: undefined;
}
export const IMcpOAuthStore: ServiceIdentifier<IMcpOAuthStore> =
createDecorator<IMcpOAuthStore>('mcpOAuthStore');
const CREDENTIALS_SCOPE = 'credentials/mcp';
export function createMcpOAuthStore(docs: IAtomicDocumentStore): McpOAuthStore {
return {
async read<T>(key: string): Promise<T | undefined> {
try {
return await docs.get<T>(CREDENTIALS_SCOPE, key);
} catch {
return undefined;
}
},
write(key, data) {
return docs.set(CREDENTIALS_SCOPE, key, data);
},
remove(key) {
return docs.delete(CREDENTIALS_SCOPE, key);
},
};
}
export class McpOAuthStoreAdapter implements IMcpOAuthStore {
declare readonly _serviceBrand: undefined;
private readonly delegate: McpOAuthStore;
constructor(@IAtomicDocumentStore docs: IAtomicDocumentStore) {
this.delegate = createMcpOAuthStore(docs);
}
read<T>(key: string): Promise<T | undefined> {
return this.delegate.read<T>(key);
}
write(key: string, data: unknown): Promise<void> {
return this.delegate.write(key, data);
}
remove(key: string): Promise<void> {
return this.delegate.remove(key);
}
}
registerScopedService(
LifecycleScope.App,
IMcpOAuthStore,
McpOAuthStoreAdapter,
ScopeActivation.OnDemand,
'mcpConfig',
);

View file

@ -42,7 +42,11 @@ import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { IWireService } from '#/wire/wire';
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
import { ISessionIndex } from '#/app/sessionIndex/sessionIndex';
import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle';
import { resumeSessionById } from '#/app/workspaceLifecycle/sessionLookup';
import {
IInstantiationService,
type ServicesAccessor,
} from '#/_base/di/instantiation';
import { ErrorCodes, Error2 } from '#/errors';
import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent';
@ -54,11 +58,22 @@ const MAX_PAGE_SIZE = 100;
export class MessageLegacyService implements IMessageLegacyService {
declare readonly _serviceBrand: undefined;
/**
* Stable accessor over the App container (same wrapper `Scope.accessor`
* uses one `invokeFunction` per resolution, never a stashed transient
* accessor), feeding the `sessionLookup` composition helpers.
*/
private readonly services: ServicesAccessor;
constructor(
@ISessionLifecycleService private readonly lifecycle: ISessionLifecycleService,
@IInstantiationService instantiation: IInstantiationService,
@ISessionIndex private readonly index: ISessionIndex,
@IAppendLogStore private readonly appendLog: IAppendLogStore,
) {}
) {
this.services = {
get: (id) => instantiation.invokeFunction((accessor) => accessor.get(id)),
};
}
async list(sessionId: string, query: MessageListQuery): Promise<PageResponse<Message>> {
const all = await this.loadMessages(sessionId);
@ -108,7 +123,8 @@ export class MessageLegacyService implements IMessageLegacyService {
throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`);
}
const session = await this.lifecycle.resume(sessionId);
// The shared index → handler → session-lifecycle composition.
const session = await resumeSessionById(this.services, sessionId);
if (session === undefined) return [];
const agent = await ensureMainAgent(session);

View file

@ -1,7 +1,7 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { parseFrontmatter } from '#/app/skillCatalog/parser';
import { parseFrontmatter } from '#/_base/text/frontmatter';
import type { PluginCommandDef } from './types';

View file

@ -11,8 +11,8 @@ import path from 'node:path';
import { Error2, PluginErrors } from '#/errors';
import type { HookDef } from '#/agent/externalHooks/types';
import type { McpServerConfig } from '#/agent/mcp/config-schema';
import type { AgentFileRoot } from '#/app/agentFileCatalog/types';
import type { McpServerConfig } from '#/mcpCore/config-schema';
import type { PluginAgentRoot } from './types';
import { discoverFileSkills } from '#/app/skillCatalog/fileSkillDiscovery';
import type { SkillDiscoveryResult } from '#/app/skillCatalog/skillDiscovery';
import type { SkillRoot } from '#/app/skillCatalog/types';
@ -314,8 +314,8 @@ export class PluginManager {
return roots;
}
pluginAgentRoots(): readonly AgentFileRoot[] {
const roots: AgentFileRoot[] = [];
pluginAgentRoots(): readonly PluginAgentRoot[] {
const roots: PluginAgentRoot[] = [];
for (const record of this.records.values()) {
if (!record.enabled || record.state !== 'ok' || record.manifest === undefined) continue;
for (const dir of record.manifest.agents ?? []) {

View file

@ -2,7 +2,7 @@ import { readdir, readFile, realpath, stat } from 'node:fs/promises';
import path from 'node:path';
import { HookDefSchema, type HookDefConfig } from '#/agent/externalHooks/configSection';
import { McpServerConfigSchema, type McpServerConfig } from '#/agent/mcp/config-schema';
import { McpServerConfigSchema, type McpServerConfig } from '#/mcpCore/config-schema';
import {
PLUGIN_NAME_REGEX,

View file

@ -10,13 +10,13 @@
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { Event } from '#/_base/event';
import type { HookDef } from '#/agent/externalHooks/types';
import type { McpServerConfig } from '#/agent/mcp/config-schema';
import type { AgentFileRoot } from '#/app/agentFileCatalog/types';
import type { McpServerConfig } from '#/mcpCore/config-schema';
import type { SkillRoot } from '#/app/skillCatalog/types';
import type {
EnabledPluginSessionStart,
EnabledPluginSystemPrompt,
PluginAgentRoot,
PluginCommandDef,
PluginInfo,
PluginSummary,
@ -60,7 +60,7 @@ export interface IPluginService {
listPluginCommands(): Promise<readonly PluginCommandDef[]>;
checkUpdates(): Promise<readonly PluginUpdateStatus[]>;
pluginSkillRoots(): Promise<readonly SkillRoot[]>;
pluginAgentRoots(): Promise<readonly AgentFileRoot[]>;
pluginAgentRoots(): Promise<readonly PluginAgentRoot[]>;
enabledSessionStarts(): Promise<readonly EnabledPluginSessionStart[]>;
enabledSystemPrompts(): Promise<readonly EnabledPluginSystemPrompt[]>;
enabledMcpServers(): Promise<Record<string, McpServerConfig>>;

Some files were not shown because too many files have changed in this diff Show more