mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-31 12:05:17 +00:00
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:
commit
531dbd3774
366 changed files with 13777 additions and 6345 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.**
|
||||
|
|
|
|||
|
|
@ -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** (L0–L7), **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` (L0–L7) — **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`").
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
5
.changeset/fuzzy-pandas-refresh.md
Normal file
5
.changeset/fuzzy-pandas-refresh.md
Normal 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.
|
||||
5
.changeset/kimi-web-markstream-monaco.md
Normal file
5
.changeset/kimi-web-markstream-monaco.md
Normal 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.
|
||||
5
.changeset/tui-drop-forced-full-redraws.md
Normal file
5
.changeset/tui-drop-forced-full-redraws.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Reduce frequent full-screen redraws in the TUI.
|
||||
|
|
@ -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
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' ? (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
211
apps/kimi-inspect/src/components/WorkspaceDirBrowser.tsx
Normal file
211
apps/kimi-inspect/src/components/WorkspaceDirBrowser.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
apps/kimi-inspect/src/components/WorkspaceServicesView.tsx
Normal file
93
apps/kimi-inspect/src/components/WorkspaceServicesView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,6 +6,21 @@ outline: 2
|
|||
|
||||
本页记录 Kimi Code CLI 每个版本的变更内容。
|
||||
|
||||
## 0.31.0(2026-07-30)
|
||||
|
||||
### 新功能
|
||||
|
||||
- TUI 支持 Markdown 定义的自定义 Agent。
|
||||
- 新增 /secondary_model 斜杠命令,用于配置子 Agent 使用的辅助模型(实验性功能,需先在 /experiments 中开启)。
|
||||
- 插件可贡献自定义 Agent,自动发现并可用于子 Agent 委派。
|
||||
- 插件可贡献系统提示词,通过 `kimi.plugin.json` 中的 `systemPrompt` 或 `systemPromptPath` 声明。
|
||||
|
||||
### 修复
|
||||
|
||||
- 移除 TaskOutput 工具的阻塞式 `block`/`timeout` 等待。
|
||||
- 修复会话元数据缓存早于 archived 标记时会话选择器缺少会话的问题。
|
||||
- 修复部分请求未能正确传递请求头的问题。
|
||||
|
||||
## 0.30.0(2026-07-29)
|
||||
|
||||
### 新功能
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@
|
|||
inherit (finalAttrs) pname version src pnpmWorkspaces;
|
||||
inherit pnpm;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-bL1AaInlb8dE+ua7a6llvQWkibEwEzfI3oQW5IOpX6I=";
|
||||
hash = "sha256-FNxf3Zpr+s6ubtLIqXCkVgecBGejGM4hovUNVRnNoX4=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 创建中要 B,B 创建中又要 A——容器会抛 `CyclicDependencyError`
|
|||
|
||||
### 9.2 为什么不允许
|
||||
|
||||
- scope 分层让正常依赖天然是 DAG(Agent → Session → App 向上找),一个环几乎总是设计味道。
|
||||
- scope 分层让正常依赖天然是 DAG(Agent → Session → Workspace → App 向上找),一个环几乎总是设计味道。
|
||||
- 靠「让环刚好能跑」会把构造顺序变成隐式约定,难调试、难排错。
|
||||
|
||||
所以 v2 的立场是:**依赖图必须是无环的。**
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@
|
|||
- W3 Session 域借 main agent 的 wire 写(todo/cron),main 缺失时**静默丢写**
|
||||
(`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)`,实现仍走 appendLogStore(W4 的接口层收口:
|
||||
唯一入口,不再散落在 sessionLifecycle 里手写)。
|
||||
唯一入口,不再散落在 workspaceHandler 里手写)。
|
||||
- App scope 一条逻辑流(config/model catalog/session 生命周期),取代
|
||||
`IEventService`(V4)——App 流本就无持久化,纯接口替换。
|
||||
- **Topic = 流上的类型化过滤视角**,不是独立机制。订阅方用
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
314
packages/agent-core-v2/docs/state-manifest.d.ts
vendored
314
packages/agent-core-v2/docs/state-manifest.d.ts
vendored
|
|
@ -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 */ {
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 L1–L2 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).
|
||||
|
|
|
|||
71
packages/agent-core-v2/src/_base/contribution/registry.ts
Normal file
71
packages/agent-core-v2/src/_base/contribution/registry.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
54
packages/agent-core-v2/src/_base/text/frontmatter.ts
Normal file
54
packages/agent-core-v2/src/_base/text/frontmatter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
34
packages/agent-core-v2/src/_base/utils/paths.ts
Normal file
34
packages/agent-core-v2/src/_base/utils/paths.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* root→leaf `.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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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');
|
||||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -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');
|
||||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> =
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
75
packages/agent-core-v2/src/app/git/workTree.ts
Normal file
75
packages/agent-core-v2/src/app/git/workTree.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
80
packages/agent-core-v2/src/app/mcpConfig/oauthStore.ts
Normal file
80
packages/agent-core-v2/src/app/mcpConfig/oauthStore.ts
Normal 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',
|
||||
);
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? []) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue