diff --git a/.agents/skills/agent-core-dev/SKILL.md b/.agents/skills/agent-core-dev/SKILL.md index c4b60edbb5..b35ea3a033 100644 --- a/.agents/skills/agent-core-dev/SKILL.md +++ b/.agents/skills/agent-core-dev/SKILL.md @@ -14,7 +14,7 @@ description: Use when developing in packages/agent-core-v2 (the DI × Scope agen ```text Orient → Design → Implement → Test → Verify │ │ │ │ │ - │ │ │ │ └─ lint:domain · typecheck · test · dep graph · red lines + │ │ │ │ └─ lint:imports · typecheck · test · dep graph · red lines │ │ │ └─ test.md │ │ └─ implement.md (+ errors.md · flags.md · permission.md) │ └─ design.md @@ -46,7 +46,7 @@ End-to-end procedures that span the stages. Reach for these before reading the s - Topic: [Permission](permission.md) — risk-only chain-of-responsibility kernel, harness constraints and product reviews as domain `onBeforeExecuteTool` veto listeners (`veto` / `allow` / `pass` / cold `waitUntil` factories), shared `toolApproval` round-trip, policy registry + composer, `modes`/`agentTypes` metadata, `resolveExecution`/`accesses`. - Topic: [Telemetry](telemetry.md) — emitting events via `ITelemetryService`, context propagation, and appender destinations (`ConsoleAppender` / `CloudAppender`). - [Stage 4 — Test](test.md): resolve the system under test by interface, pick `TestInstantiationService` vs `createScopedTestHost`, shared stubs, service groups, teardown. -- [Stage 5 — Verify & submit](verify.md): `lint:domain`, `typecheck`, `test`, and the pre-submit checklist. +- [Stage 5 — Verify & submit](verify.md): `lint:imports`, `typecheck`, `test`, and the pre-submit checklist. ## How to use this skill @@ -67,4 +67,4 @@ Invariants that hold across every stage. Each is expanded in the stage file note 9. Throw coded errors; register codes centrally; branch on `code` across the wire, never `instanceof`. (errors.md) 10. Gate unreleased behavior behind a flag contributed via `registerFlagDefinition` and resolved through `IFlagService.enabled(id)`; no ad-hoc env toggles. (flags.md) 11. Tests resolve the SUT by interface; shared stubs live under `test/`, never `src/`. (test.md) -12. Config is the preference registry: only preferences that are persistable, schema'd, and user/operator-facing go in `IConfigService`. Domain-specific config (including env-only operational toggles) goes through `registerSection` + `envOverlay`. Facts → `IBootstrapService` (kept domain-agnostic — never add cron/flags/model state); session state → Session scope; constants → code. Business domains never call `IBootstrapService.getEnv()` directly. (config.md) +12. Config is the preference registry: only preferences that are persistable, schema'd, and user/operator-facing go in `IConfigService`. Domain-specific config (including env-only operational toggles) goes through `registerSection` + `envOverlay`. Facts → `IBootstrapService`, and host invocation arguments (CLI flags, host identity headers, prompt identity) → `BootstrapInput.args` / `IBootstrapService.args` — never new per-domain runtime-options services; domain runtime state (cron/flags/model) never goes onto `IBootstrapService`; session state → Session scope; constants → code. Business domains never call `IBootstrapService.getEnv()` directly. (config.md) diff --git a/.agents/skills/agent-core-dev/align.md b/.agents/skills/agent-core-dev/align.md index 82e70fa3be..1186c039ef 100644 --- a/.agents/skills/agent-core-dev/align.md +++ b/.agents/skills/agent-core-dev/align.md @@ -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 `sessionLifecycle` (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. @@ -143,7 +143,7 @@ Re-wire the dependencies you inventoried in step 1, now across the new v2 Servic - **Domain direction** — foundational layers must not know upstream ones. A cycle means a v1 relative import is now pointing the wrong way; extract a third Service or invert the notification into an event. - **Durable facts** — state changes that must be recorded / replayed / projected across agents go on the wire (`wireRecord`), not a direct call alone. -Run `lint:domain` (verify.md) as soon as the dependencies compile — it catches direction violations early. +Run `lint:imports` (verify.md) as soon as the dependencies compile — it catches v1 imports and kosong boundary violations early. ### 7. Port the business logic @@ -188,7 +188,7 @@ import { KimiError, type ErrorCode } from '#/_base/errors'; Red lines: - Do not copy a v1 file and "fix imports". Re-split first (steps 2–6); a straight copy carries v1's implicit-singleton assumptions into v2 and creates the `Map`-at-`App` anti-pattern. -- Do not leave v1 relative imports (`from '../x/...'`) in v2 — use the `#/...` alias and respect the domain layers. +- Do not leave v1 relative imports (`from '../x/...'`) in v2 — use the `#/...` alias. - Do not preserve a v1 behavior just because it exists; if the split reveals it was a workaround for the missing scope tree, drop it. ### 8. Port the tests @@ -218,7 +218,7 @@ const svc = ix.get(IXxxService); Before submitting a port: - [ ] Every piece of v1 state landed in a v2 Service whose scope matches its identity (no `Map` at `App`). -- [ ] Each v1 dependency now points in the right scope and domain direction; `lint:domain` passes. +- [ ] Each v1 dependency now points in the right scope direction; `lint:imports` passes. - [ ] Registrations use `registerScopedService` with an explicit scope and domain name; no `registerSingleton` remains. - [ ] Imports use the `#/...` alias; no v1 relative (`../../di`, `../../errors`) imports remain. - [ ] Errors are co-located coded errors; flags go through `IFlagService`. diff --git a/.agents/skills/agent-core-dev/commit-align.md b/.agents/skills/agent-core-dev/commit-align.md index d0fde63d96..90caa638c2 100644 --- a/.agents/skills/agent-core-dev/commit-align.md +++ b/.agents/skills/agent-core-dev/commit-align.md @@ -57,7 +57,7 @@ Keep the recommendation to the commit's footprint. If it keeps growing, that is ### 6. Verify -Point at the checks that cover the fix, per [verify.md](verify.md): `lint:domain`, `typecheck`, and the relevant `test`. Note the expected outcome rather than asserting you ran it if you did not. +Point at the checks that cover the fix, per [verify.md](verify.md): `lint:imports`, `typecheck`, and the relevant `test`. Note the expected outcome rather than asserting you ran it if you did not. ## Output shape diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md index 3c4d2402e8..2eb48516eb 100644 --- a/.agents/skills/agent-core-dev/config.md +++ b/.agents/skills/agent-core-dev/config.md @@ -33,7 +33,7 @@ A value belongs in Config **iff** it satisfies all of: If it fails any rule, it is not Config: - **Fact** (CI, platform, proxy, `HOME`) → a structured fact on - `IBootstrapService` (the L1 startup snapshot), not Config. + `IBootstrapService` (the startup snapshot), not Config. - **Derived convention** (`configPath`, `logsDir`) → `IBootstrapService` / code. - **Session runtime state** (active model, plan mode) → a Session-scoped service in the owning domain (e.g. `IProfileService`), not `config`. @@ -42,9 +42,16 @@ If it fails any rule, it is not Config: **`IBootstrapService` is domain-agnostic.** It holds only generic facts shared by all domains — the env bag, resolved paths, and host facts (`platform`, `arch`, -`cwd`, `osHomeDir`, `isCI`, …). It must **never** hold state tied to a specific -upper domain (no `cron`, no `flags`, no feature-specific fields): that couples -the foundational layer to an upstream one. +`cwd`, `osHomeDir`, `isCI`, …) — plus the host's process-level invocation +arguments in `args` (explicit `agentFiles` / `skillDirs`, `requestHeaders`, +prompt identity). `args` mirrors VS Code's `NativeParsedArgs` on the +environment service: the host states them once via `BootstrapInput.args` at +the composition root, and downstream services read them from +`IBootstrapService.args` instead of through per-domain runtime-options +services (do not add new `IXxxRuntimeOptions` services or seed functions for +host parameters). What must **never** land on `IBootstrapService` is state +tied to a specific upper domain (no `cron`, no `flags`, no feature-specific +fields): that couples the foundational layer to an upstream one. Any value that belongs to a specific domain — including env-only operational toggles (`KIMI_CRON_*`, `KIMI_CODE_EXPERIMENTAL_*`), model parameters, or feature @@ -113,7 +120,7 @@ A config section is identified by a camelCase domain key (`'providers'`, `'think Ownership rules: - **One owner per section.** `registerSection` throws if a domain is registered twice. -- **The domain that consumes a config owns its schema.** This is what keeps `config` (L2) from importing higher domains: `config` must not import `externalHooks` / `permissionRules` / `provider` / `kosong` / etc. for a section's schema. If a schema needs a domain's types, the schema lives in that domain. +- **The domain that consumes a config owns its schema.** This is what keeps `config` from depending on its consumers: `config` must not import `externalHooks` / `permissionRules` / `provider` / `kosong` / etc. for a section's schema. If a schema needs a domain's types, the schema lives in that domain. - **Demand-driven.** Do not register sections for config that no domain reads yet; a section appears (with its schema in the owning domain) only when a consumer appears. ## Env bindings @@ -251,20 +258,20 @@ The authoritative, always-current list of registered sections — rendered in th `config` must not import from any of these owner domains; that is the whole reason the schemas, TOML normalization, and env overlays live with their owners. -## Layering & scope +## Scope & dependencies -- `config` is **L2**. Domains that own sections import `config` (for `IConfigRegistry` / `IConfigService`) and must be at L2 or higher; lower layers need an entry in `ALLOWED_EXCEPTIONS` (e.g. `kosong>config`, `kosong>provider`). -- Cross-domain type sharing for a config type may need an exception too (e.g. `plugin>mcp` for `McpServerConfig`). Prefer importing the type from the owning domain over re-declaring it. +- `config` is a low-level capability: domains that own sections import `config` (for `IConfigRegistry` / `IConfigService`), never the reverse — section schemas live in the owning domain. +- Cross-domain type sharing for a config type: prefer importing the type from the owning domain over re-declaring it (e.g. `plugin` imports `McpServerConfig` from the MCP config schema). - `IConfigRegistry` / `IConfigService` are **App**. Agent scope services may inject App services via ancestor lookup. - `config` never imports a higher domain and holds no section schemas of its own; if a section needs a type from another domain, that schema lives in that domain. ## Red lines (this topic) - One owner per section; `registerSection` throws on duplicate domains. -- `config` (L2) never imports a higher domain — keep section schemas in the owning domain. +- `config` never imports the domains that consume it — keep section schemas in the owning domain. - Config is the **preference registry**: register only values that are preferences, persistable, schema'd, and user/operator-facing. Facts → `IBootstrapService`; session state → Session scope; constants → code. - Business domains read `config.get(...)` or structured `IBootstrapService` facts; never call `IBootstrapService.getEnv()` directly — only `config` reads the raw env bag to build overlays. -- Keep `IBootstrapService` domain-agnostic: never add state tied to a specific upper domain (cron, flags, model params, …). Domain-specific config goes through `registerSection` + `envBindings`, read via `config.get(...)`. +- Keep `IBootstrapService` domain-agnostic: host invocation arguments (CLI flags, host identity headers, prompt identity) go into `BootstrapInput.args` / `IBootstrapService.args` — never into new per-domain runtime-options services; domain runtime state (cron, flags, model params, …) never goes onto `IBootstrapService` at all. Domain-specific config goes through `registerSection` + `envBindings`, read via `config.get(...)`. - Do not pass a whole config bag via options; read each section through `IConfigService`. There is no `KimiConfig` object — config is a registry of owner-owned sections. - `config.toml` is snake_case on disk, camelCase in memory — never write camelCase keys to disk, and never write to `config.toml` except through `IConfigService.set/replace`. - Reading config / calling `configure(...)` / switching model at runtime must not rewrite `config.toml`; runtime state lives in memory and the session wireRecord, not the file. diff --git a/.agents/skills/agent-core-dev/design.md b/.agents/skills/agent-core-dev/design.md index c9b4c2f0c5..bf94e46c73 100644 --- a/.agents/skills/agent-core-dev/design.md +++ b/.agents/skills/agent-core-dev/design.md @@ -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: @@ -145,25 +147,17 @@ Add one anti-rot heuristic to keep the graph from collapsing into a clique: Once a foundational component knows about an upstream scenario, it can no longer be reused by other scenarios and will almost always create a cycle. -### The natural layers of this repo +### The boundaries of this repo -`agent-core-v2` is stratified into eight dependency layers, **L0–L7** (the `Ln` number in file headers — see orient.md for the full table and the representative domains). A domain at layer `L` may import only domains at layer `<= L`; lower layers never reach upward. `lint:domain` enforces this from the `DOMAIN_LAYER` map in `scripts/check-domain-layers.mjs`. +`agent-core-v2` has no mechanical domain-layer numbering — dependency direction is the judgment rule above, applied per domain. What remains enforceable is a small set of specific boundaries (`lint:imports`, `scripts/check-import-boundaries.mjs`): -The tiers, from lowest to highest: +- v2 never imports v1 (`@moonshot-ai/agent-core`). +- The kosong subtree keeps its strict internal order (`contract ← protocol ← provider/model`, purity bans, the `provider/bases` registration boundary). -- **L0 — base infrastructure** (`_base`, errors, wire types). -- **L1 — bridges & low-level capabilities** (logging, telemetry, event bus, environment, storage). -- **L2 — data & cross-cutting capabilities** (records, config, providers, auth, workspace registry). -- **L3 — registries & capabilities** (tools, permissions, flags, skills, plugins). -- **L4 — agent behaviour** (turn, loop, prompt, profile, context, goal, plan, swarm). -- **L5 — async lifecycle** (background, MCP, cron, sub-agent tools). -- **L6 — coordination** (session, agent/session lifecycle, interactions, terminal). -- **L7 — boundary / edge** (`gateway`, `rpc`, approval/question, the `*Legacy` v1 adapters). +Two standing red lines on top of that: -Red lines: - -- The **L0/L1 substrate** never imports a higher business layer. -- Business logic never depends on the **L7 edge** layer — business code should not know REST / WebSocket exist. +- The **base substrate** (`_base`, errors, wire types) never depends on any business domain. +- Business logic never depends on the **edge** (`gateway`, `rpc`, the `*Legacy` v1 adapters) — business code should not know REST / WebSocket exist. - A cycle means knowledge was placed backwards: extract a third, more foundational Service, or invert the "notification" half into an event. > Capability → orchestrator (e.g. `prompt → turn`) is allowed and present in this repo; the real red line is *inverted reuse* — a foundational / lower Service depending on a specific / upper one. @@ -189,8 +183,9 @@ domain: `` (owning scope: ) │ └─ (accessor) @ ├─ exposes (interfaces I provide, by scope) │ ├─ App : -│ ├─ Session : -│ └─ Agent : +│ ├─ Workspace : +│ ├─ Session : +│ └─ Agent : └─ depends (what I inject) tag = calling style └─ @ direct/event/hook — ``` @@ -228,42 +223,49 @@ Read it as: Worked example — `sessionLifecycle`: ```text -domain: `sessionLifecycle` (owning scope: App) +domain: `sessionLifecycle` (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 : ISessionLifecycleService — 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`: ```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(ISessionLifecycleService) + │ └── resolve runs inside the Workspace scope + ▼ + Workspace scope (workspaceId) + SessionLifecycleService ──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`. +- **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)** → `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`. diff --git a/.agents/skills/agent-core-dev/domain-boundaries.md b/.agents/skills/agent-core-dev/domain-boundaries.md index c2f62cc9c2..cd3eb8ee64 100644 --- a/.agents/skills/agent-core-dev/domain-boundaries.md +++ b/.agents/skills/agent-core-dev/domain-boundaries.md @@ -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 | `sessionLifecycle` | 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 | diff --git a/.agents/skills/agent-core-dev/edge-exposure.md b/.agents/skills/agent-core-dev/edge-exposure.md index 738326d1ac..9105bb9488 100644 --- a/.agents/skills/agent-core-dev/edge-exposure.md +++ b/.agents/skills/agent-core-dev/edge-exposure.md @@ -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' }, ... }, }; ``` @@ -90,7 +92,7 @@ Read = `GET`, write = `POST`. `sid` = `session_id`, `aid` = `agent_id`. | `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`) diff --git a/.agents/skills/agent-core-dev/flags.md b/.agents/skills/agent-core-dev/flags.md index ba4ea424b5..6160738971 100644 --- a/.agents/skills/agent-core-dev/flags.md +++ b/.agents/skills/agent-core-dev/flags.md @@ -94,8 +94,8 @@ if (!this.flags.enabled('my_feature')) return; ## Layering & scope -- Domain `flag` is registered at **L3**. It imports only `config` (L2) downward. -- It cannot live in `_base` (L0): registering/reading the config section requires importing `config`, and L0 must not import L2. +- Domain `flag` imports only `config` downward. +- It cannot live in `_base`: registering/reading the config section requires importing `config`, and `_base` is pure infrastructure that must not know any business domain. - Scope: `IFlagRegistry` and `IFlagService` are both `App`. Env + config are process-global inputs, so there is no per-session/agent state. Flag definitions are contributed at **import time** (top-level `registerFlagDefinition` calls), so they are queued before any scope is created and drained when `FlagRegistryService` is first instantiated — before `IFlagService` is first resolved. - Tests build `FlagService` + `FlagRegistryService` directly with a real `ConfigRegistry`/`ConfigService` and an injected env map, then `register` the flags they exercise. @@ -105,4 +105,4 @@ if (!this.flags.enabled('my_feature')) return; - Contribute each flag from the **owning domain's** `flag.ts` (`src//flag.ts`) via a top-level `registerFlagDefinition` call; there is no central catalog to edit. The directory names the domain, so the file is just `flag.ts`. - `env` must start with `KIMI_CODE_EXPERIMENTAL_`, be unique, and not equal `KIMI_CODE_EXPERIMENTAL_FLAG`; `id` must not be `flag`. - `FlagId` is `string` (decentralized registration) — do not reintroduce a central `FLAG_DEFINITIONS` array or a derived literal union. -- `flag` lives at L3 and `App` scope — never in `_base`, never per-session. +- `flag` lives at `App` scope — never in `_base`, never per-session. diff --git a/.agents/skills/agent-core-dev/implement.md b/.agents/skills/agent-core-dev/implement.md index f8df820ed4..1f860ab902 100644 --- a/.agents/skills/agent-core-dev/implement.md +++ b/.agents/skills/agent-core-dev/implement.md @@ -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.** diff --git a/.agents/skills/agent-core-dev/orient.md b/.agents/skills/agent-core-dev/orient.md index 54f74f0083..7370aa6357 100644 --- a/.agents/skills/agent-core-dev/orient.md +++ b/.agents/skills/agent-core-dev/orient.md @@ -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, } ``` @@ -47,33 +49,20 @@ A child scope sees its ancestors; a parent never sees its children. Resolution w Deterministic: **child scopes die first; within one scope, instances dispose in reverse construction order** (last constructed, first disposed). Business code declares which tier it lives in and never disposes by hand. -## The `(Ln)` layer number in headers +## Import boundaries -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: +There is no domain-layer numbering — a domain may import any other domain, guided by the dependency-direction judgment in design.md. The only mechanically enforced import boundaries are (`lint:imports`, `scripts/check-import-boundaries.mjs`): -- `LifecycleScope` (App=0 / Session=1 / Agent=2) — **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. - -| Layer | Role | Representative domains | -|---|---|---| -| L0 | base infrastructure | `_base`, `errors`, `llmProtocol` | -| L1 | bridges & low-level capabilities | `log`, `telemetry`, `event`, `environment`, `bootstrap`, `storage` | -| L2 | data & cross-cutting capabilities | `records`, `wireRecord`, `config`, `provider`, `auth`, `workspace` | -| L3 | registries & capabilities | `tool`, `toolRegistry`, `permission*`, `flag`, `skill`, `plugin` | -| L4 | agent behaviour | `turn`, `loop`, `prompt`, `profile`, `contextMemory`, `goal`, `plan`, `swarm` | -| L5 | async lifecycle | `background`, `mcp`, `cron`, `agentTool` | -| L6 | coordination | `session`, `agentLifecycle`, `sessionMetadata`, `interaction`, `terminal`, `undo` | -| L7 | boundary / edge | `gateway`, `rpc`, `approval`, `question`, `*Legacy` | +- v2 never imports v1 (`@moonshot-ai/agent-core` or any subpath). +- The kosong subtree (`src/kosong/{contract,protocol,provider,model}`) keeps its strict internal order (`contract ← protocol ← provider/model`), purity bans (no SDKs in `contract`/`protocol`), and the `provider/bases` registration boundary. ## File-header comment convention `packages/agent-core-v2/AGENTS.md` mandates a header-only comment style: - **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 (Ln) — . `` 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. +- **Identity line first.** Start with `` `` domain — . `` 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.** `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** (`.ts`) state the public contract + scope: which `IXxx` they define and what it is for. - **Impl files** (`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** (`.ts` / `.contrib.ts`) state what they register into the target domain (e.g. "registers the `log` config section into `config`"). @@ -83,7 +72,7 @@ Impl file example (`sessionMetadataService.ts`): ```ts /** - * `sessionMetadata` domain (L6) — `ISessionMetadata` implementation. + * `sessionMetadata` domain — `ISessionMetadata` implementation. * * Persists the session metadata document (`state.json`) through the `storage` * access-pattern store (`IAtomicDocumentStore`), rooted at the `metaScope` diff --git a/.agents/skills/agent-core-dev/persistence.md b/.agents/skills/agent-core-dev/persistence.md index 832a2fe806..c62555a38f 100644 --- a/.agents/skills/agent-core-dev/persistence.md +++ b/.agents/skills/agent-core-dev/persistence.md @@ -190,7 +190,7 @@ export interface IAppendLogStore { ## Platform primitives are deployment-coupled, not core abstractions -`hostFs` (local filesystem) is a **platform primitive** used only by local backends (`FileStorageService`, `LocalFileSystemBackend`, `LocalSkillCatalog`, `HostFolderBrowser`). It is **not** a core abstraction and must not appear in L2/L3 dependency graphs. A server deployment swaps those backends for DB / S3 implementations and never registers `hostFs`. +`hostFs` (local filesystem) is a **platform primitive** used only by local backends (`FileStorageService`, `LocalFileSystemBackend`, `LocalSkillCatalog`, `HostFolderBrowser`). It is **not** a core abstraction and must not appear in business-domain dependency graphs. A server deployment swaps those backends for DB / S3 implementations and never registers `hostFs`. ## Red lines (this topic) @@ -199,6 +199,6 @@ export interface IAppendLogStore { - Name generic Stores by access pattern (`IAppendLogStore` / `IAtomicDocumentStore` / `IBlobStore`), never by business concept (`IRecordStore` / `IConfigStore`). - Business-specific Stores (unique query semantics) are named after the domain (`ISessionIndex`). - `IFileSystemStorageService` is the filesystem byte-layer interface; non-filesystem backends implement the **Store** interfaces directly. Route backends by binding a different Store implementation at the composition root, not by overloading `scope`. -- `hostFs` is a local-only platform primitive; L2/L3 domains must not import `node:fs` or `hostFs` directly. +- `hostFs` is a local-only platform primitive; business domains must not import `node:fs` or `hostFs` directly. - Only the file-backed bootstrap (`FileBootstrapService`) and file backends import `pathe`; business domains do not. - Do not create a pass-through `Store` that only forwards `read/write` — a Store must hide a real access-pattern concern, or it is noise; use `IFileSystemStorageService` directly instead. diff --git a/.agents/skills/agent-core-dev/server-align.md b/.agents/skills/agent-core-dev/server-align.md index 7e1ab7b99a..ce896cc298 100644 --- a/.agents/skills/agent-core-dev/server-align.md +++ b/.agents/skills/agent-core-dev/server-align.md @@ -69,7 +69,7 @@ Resolve the v2 Service that will back the route. Two cases: **Case A — the v2 native Service already matches the v1 contract.** Use it directly. Most data/command Services (`IConfigService`, `IWorkspaceService`, `IApprovalService`, `IQuestionService`, `IFileStore`, …) land here: the route is a thin adapter that resolves the scope, calls the method, and wraps the result. Examples: `routes/config.ts`, `routes/messages.ts`, `routes/questions.ts`, `routes/files.ts`. -**Case B — the v1 contract needs behavior that would distort the v2 domain.** Introduce a **`*LegacyService`** — an L7 edge adapter that implements the v1 contract **on top of** the v2 native Service, leaving the native Service untouched. The v2 native Service keeps serving `/api/v2`; the LegacyService serves `/api/v1`. +**Case B — the v1 contract needs behavior that would distort the v2 domain.** Introduce a **`*LegacyService`** — an edge adapter that implements the v1 contract **on top of** the v2 native Service, leaving the native Service untouched. The v2 native Service keeps serving `/api/v2`; the LegacyService serves `/api/v1`. Reach for a LegacyService when **any** hold: @@ -127,7 +127,7 @@ registerScopedService( Conventions: - **Name** the domain `Legacy` and the interface with the scope prefix, `ILegacyService` (e.g. `prompt` / `IAgentPromptService`), per service-authoring.md. -- **Header comment** must say it is an `L7 edge adapter` and name both the v1 contract it implements and the native v2 Service it leaves untouched (see `prompt.ts`). +- **Header comment** must say it is an `edge adapter` and name both the v1 contract it implements and the native v2 Service it leaves untouched (see `prompt.ts`). - **Scope** = the lifetime of the *legacy* state it holds (the `prompt` queue is per-agent → `LifecycleScope.Agent`). Apply [orient.md](orient.md) / [design.md](design.md) normally — a LegacyService is not exempt from scope rules. - **Delegate, do not duplicate** business logic. The LegacyService translates the v1 contract into native-Service calls and translates results back; the real work stays in the native Service. - **Contract types come from the v1 wire schema homes** (the owning v2 domain contract or `kap-server/src/protocol`), so the interface cannot drift from the wire shape. @@ -206,7 +206,7 @@ Where the route mirrors v1, the test is the regression guard for the schema-fide - `pnpm -C packages/kap-server test` — server routes green. - `pnpm -C packages/kap-server test` — server routes green (incl. any wire-schema guards). - `pnpm -C packages/agent-core-v2 test` — native + Legacy Service tests green. -- `pnpm -C packages/agent-core-v2 run lint:domain` — a LegacyService is still inside the domain layers (edge adapter, L7); it must not pull business code into the edge or invert scope direction. +- `pnpm -C packages/agent-core-v2 run lint:imports` — the import boundaries (v1 ban, kosong subtree) still hold for a LegacyService. - `pnpm -C packages/klient test` (optionally with `KIMI_SERVER_URL` for the live legacy suites) when a v1 parity scenario exists. ## Worked example — porting v1 `/sessions/:sid/prompts` @@ -235,11 +235,11 @@ Before submitting a server-align change: - [ ] Request and response schemas come from their owning home (the `agent-core-v2` domain contract or `packages/kap-server/src/protocol`); no inline re-declaration in server-v2. - [ ] Existing schema fields are unchanged in name, type, and semantics; only optional fields added (if any). - [ ] Native v2 Service left clean; v1-only behavior isolated in a `Legacy` / `ILegacyService` edge adapter when the semantics diverge. -- [ ] LegacyService registered with the correct `LifecycleScope` and a header comment naming it an L7 edge adapter + the native Service it preserves. +- [ ] LegacyService registered with the correct `LifecycleScope` and a header comment naming it an edge adapter + the native Service it preserves. - [ ] Domain error codes registered in `agent-core-v2`; wire codes registered in `packages/kap-server/src/protocol`; route maps them in `sendMappedError`, matching v1's status codes and idempotent envelopes. - [ ] Route resolves the scope from the URL by `accessor.get(IX)`; no cached scope; finishes before disposal. - [ ] Tests assert the wire envelope + protocol shape; wire-shape guards added/updated where the route mirrors v1. -- [ ] `lint:domain` passes; the LegacyService did not invert scope or domain direction. +- [ ] `lint:imports` passes; the LegacyService did not invert scope direction. ## Red lines (this subskill) diff --git a/.agents/skills/agent-core-dev/service-authoring.md b/.agents/skills/agent-core-dev/service-authoring.md index 316ccf05cb..dd4ba7e355 100644 --- a/.agents/skills/agent-core-dev/service-authoring.md +++ b/.agents/skills/agent-core-dev/service-authoring.md @@ -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 `.ts` + `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('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). @@ -204,7 +204,7 @@ A scoped Service may expose a factory method that returns a **new** instance of ### Runtime state goes into the per-scope state container -Session/Agent-scope Services register their runtime state into the scope's state container (`ISessionStateService` / `IAgentStateService`, both over `_base`'s `StateRegistry`) instead of holding it in bare instance fields, so per-scope state lives in one observable place (`snapshot()` / `onDidChange`) and dies with the scope. Reference: `session/interaction/interactionService.ts`. +Workspace/Session/Agent-scope Services register their runtime state into the scope's state container (`IWorkspaceStateService` / `ISessionStateService` / `IAgentStateService`, all over `_base`'s `StateRegistry`) instead of holding it in bare instance fields, so per-scope state lives in one observable place (`snapshot()` / `onDidChange`) and dies with the scope. Reference: `session/interaction/interactionService.ts`. - Declare keys in the domain file and export them: `export const interactionPendingKey = defineState>('interaction.pending', () => new Map())` — `.` naming, factory initializers. - Inject `@ISessionStateService private readonly states` (or the Agent token) and `this.states.register(key)` per key at the top of the constructor. diff --git a/.agents/skills/agent-core-dev/verify.md b/.agents/skills/agent-core-dev/verify.md index 88d0917009..8ab7dd0958 100644 --- a/.agents/skills/agent-core-dev/verify.md +++ b/.agents/skills/agent-core-dev/verify.md @@ -6,7 +6,7 @@ Run the guards and re-scan the red lines before submitting. Run from the package (or with `--filter @moonshot-ai/agent-core-v2`): -- `pnpm --filter @moonshot-ai/agent-core-v2 lint:domain` — domain-layer / dependency-direction guard (`scripts/check-domain-layers.mjs`). Catches a domain importing a layer it must not. +- `pnpm --filter @moonshot-ai/agent-core-v2 lint:imports` — import-boundary guard (`scripts/check-import-boundaries.mjs`). Catches v1 imports (`@moonshot-ai/agent-core`) and kosong subtree violations. - `pnpm --filter @moonshot-ai/agent-core-v2 typecheck` — `tsc -p tsconfig.json --noEmit`. - `pnpm --filter @moonshot-ai/agent-core-v2 test` — `vitest run`. @@ -27,6 +27,6 @@ Then re-read the [global red lines](SKILL.md#global-red-lines) once — they cat ## Red lines (this stage) -- Do not skip `lint:domain` — it is the only automated check for the dependency-direction rules. +- Do not skip `lint:imports` — it is the only automated check for the v1-import ban and the kosong subtree rules. - Do not list internal packages in a changeset when the change enters the CLI bundle — list `@moonshot-ai/kimi-code` and describe the real change. - Never write a `major` changeset without explicit user confirmation. diff --git a/.changeset/catalog-builtin-fallback.md b/.changeset/catalog-builtin-fallback.md new file mode 100644 index 0000000000..712144ec88 --- /dev/null +++ b/.changeset/catalog-builtin-fallback.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fall back to the built-in models.dev catalog snapshot when the public catalog is unreachable, so Known third-party provider import still works offline or in blocked networks. diff --git a/.changeset/fix-dark-mono-composer.md b/.changeset/fix-dark-mono-composer.md new file mode 100644 index 0000000000..929ca229c9 --- /dev/null +++ b/.changeset/fix-dark-mono-composer.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix dark-mode monochrome controls and align the chat composer corner radius with the design system. diff --git a/.changeset/fuzzy-pandas-refresh.md b/.changeset/fuzzy-pandas-refresh.md deleted file mode 100644 index ecd821e6f2..0000000000 --- a/.changeset/fuzzy-pandas-refresh.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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. diff --git a/.changeset/kap-server-meta-experimental-flags.md b/.changeset/kap-server-meta-experimental-flags.md new file mode 100644 index 0000000000..ac946e9449 --- /dev/null +++ b/.changeset/kap-server-meta-experimental-flags.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kap-server": patch +--- + +Expose the effective experimental-flag map as `experimental_flags` on `GET /api/v1/meta`. diff --git a/.changeset/telemetry-path-redaction-unicode.md b/.changeset/telemetry-path-redaction-unicode.md new file mode 100644 index 0000000000..2dfce8cce1 --- /dev/null +++ b/.changeset/telemetry-path-redaction-unicode.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Redact non-ASCII, UNC, and forward-slash file paths from outbound telemetry. diff --git a/AGENTS.md b/AGENTS.md index e6fe240464..c21a6a1d90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,15 +17,16 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). - `apps/kimi-web`: the browser web UI, a peer to the TUI. Vue 3 + Vite + vue-i18n; talks to the server over REST + WebSocket under `/api/v1`. It must not depend on `@moonshot-ai/agent-core` (wire types are re-implemented locally). Debug against the two engines via the root `pnpm dev:v1` / `pnpm dev:v2` backend scripts — the dev Sidebar shows the active backend and switches it at runtime. See `apps/kimi-web/AGENTS.md`. - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. -- `apps/kimi-inspect`: web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session chat, and Service panels (data + trigger buttons) for the Session and Agent scopes. A left icon rail (`src/components/NavRail.tsx`) switches top-level views: the Chat workspace, the global message search (`src/components/SearchView.tsx` — cross-session full-text search over `POST /api/v1/search`, cursor-paged via a manual Load more; an exact-match checkbox maps to the API's `mode: 'literal'` substring search, which ignores sort and orders newest-first; a `live`/`index` badge on the results shows which server route served them (in-memory session transcript vs the persisted index)), the Model Catalog (`src/components/ModelCatalogView.tsx` — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies), and App Services (`src/components/AppServicesView.tsx` — the app-scope Service reflection, full width; the Agent scope stays in the Chat view's right dock (`src/components/RightPanel.tsx`) across two tabs: the `Agent` tab (`Inspector`: agent switcher + a Plan lookup card — `PlanCard` in `src/components/Inspector.tsx` — querying `GET /sessions/{id}/transcript/plan` (one tool_call_id, or every plan of the agent) via `src/transcript/api.ts`'s `fetchTranscriptPlan` — plus the agent Service panels) and the `State` tab (every key an Agent Service registered into the agent-state container, polled live via `IAgentStateService.snapshot()` — the same live diff-tree view as the session State tab, sharing `StateCard` from `src/components/StateCard.tsx`), while the Session scope has its own column right next to the session-list sidebar (`src/components/SessionPane.tsx`) with two tabs: Services (the pending-interactions card — `src/components/InteractionsCard.tsx` — plus the session Service panels) and State (every key a Session Service registered into the session-state container, read on demand via `ISessionStateService.snapshot()`)). Expanding a Model opens the model inspector inside that view: provider/model config layers plus the resolved runtime view with per-value provenance (config / override / builtin / env / synthesized), served on demand by `IModelCatalog.inspect` — the same resolution pass the runtime's `get` serves, traced via `ResolutionTraceCollector` and assembled by `kosong/model/inspection.ts`. Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `ProxyChannel` model — service-bound `IChannel`, HTTP `ProxyChannel` for calls routed to `/api/v1/debug`), typed by `agent-core-v2` Service interfaces; `GET /api/v1/debug/channels` loads the whole wire protocol 1:1 (every scoped Service, no whitelist). There is no Service-event push channel: panels fetch/refresh on demand (`Sidebar` polls react-query on a 15 s interval), and a connection failure shows a blocking "Debug surface unavailable" screen instead of falling back anywhere. Session-level coarse status is the one exception: `src/activity/` holds a second `/api/v1/ws` client (`GlobalEventsWs`) that subscribes to nothing and consumes the server-pushed global facts — `event.session.work_changed` updates a per-session activity map (`SessionActivityHub` + subscribe/version store, seeded on connect/reconnect from `GET /api/v1/sessions`), while `event.session.created` / `session.meta.updated` invalidate the `['sessions']` query; the `Sidebar` session rows render `running` / `approval` / `question` / `failed` badges from it via `useSessionActivities`. The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`) and exposes `GET /__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local kap-server instance registry (`~/.kimi-code/server/instances` + legacy `lock`) and the home token so the app can zero-config auto-connect and switch servers from the header dropdown at runtime. The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **transcript** surface instead of context memory and carries an in-chat search bar (`src/components/ChatSearchBar.tsx`): it searches the current session via `POST /api/v1/search` with `container: { session_id }` (usually served by the live route, since selecting a session resumes it), and a result click funnels through the app shell's `openSearchHit` — the same agent-switch + `ChatJump` (page-back, scroll, flash) path the global search view uses; full state is read from `GET /api/v1/sessions/{id}/transcript` (initial load = newest page, refreshes re-read from the tail backwards), older history auto-pages with `before_turn` via an IntersectionObserver sentinel at the top of the scroll view, and each timeline item is wrapped in `content-visibility: auto` + `contain-intrinsic-size` so the browser virtualizes off-screen rendering natively (no windowing library); `/api/v1/ws` is an incremental channel (`transcript.ops`, grade `block` — the cheapest grade that still carries whole-state frame upserts, dropping per-token `append` frames; `transcript.reset` is ignored by the store, surfaced only to the audit recorder via the optional `onReset` handler). The channel tracks the op-batch watermark: a dedicated `subscribe_v2` control frame carries the per-agent grades and the `transcript_since` cursor, a seq gap / reconnect / `resync_required` / append gap triggers a point-to-point catch-up (`fetchTranscriptOps` → `GET .../transcript/ops?since_seq=`), and any legacy/incomplete answer falls back to the full REST refresh. Convergence reuses `@moonshot-ai/transcript`'s L2 reducer (`src/transcript/`: REST/WS clients + store; the data model and reducer come from the package, nothing is re-implemented locally). The Transcript audit panel (`src/components/audit/`, the `Audit` tab of the chat view's right dock — `src/components/RightPanel.tsx`, fed the trail by `ChatView`'s `onTrailChange`) replays how the visible store was built: an `AuditTrail` (`src/audit/`) records every step — each REST page (request + replace/prepend), every WS frame (`transcript.ops` live/buffered/flushed/catchup, `transcript.reset`), loss signals, and prompt/cancel actions — with the resulting immutable `AgentState` per entry; the panel offers a draggable timeline plus a Diff tab (structural diff vs the previous entry: added/modified/removed colored, long strings tail-truncated, all fields kept), a full State view, and the raw Event payload. +- `apps/kimi-inspect`: web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session chat, and Service panels (data + trigger buttons) for the Session and Agent scopes. A left icon rail (`src/components/NavRail.tsx`) switches top-level views: the Chat workspace, the global message search (`src/components/SearchView.tsx` — cross-session full-text search over `POST /api/v1/search`, cursor-paged via a manual Load more; an exact-match checkbox maps to the API's `mode: 'literal'` substring search, which ignores sort and orders newest-first; a `live`/`index` badge on the results shows which server route served them (in-memory session transcript vs the persisted index)), the Model Catalog (`src/components/ModelCatalogView.tsx` — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies), and App Services (`src/components/AppServicesView.tsx` — the app-scope Service reflection, full width, joined by the Workspace Services view (`src/components/WorkspaceServicesView.tsx`) — the workspace-scope counterpart with a left sidebar directory browser (`src/components/WorkspaceDirBrowser.tsx` — server-side fs browsing over the App-scope `IHostFolderBrowser`, marking entries that are registered workspaces with their `IWorkspaceTrust` trust state, and registering a picked folder on demand via `IWorkspaceService.createOrTouch`), its proxies riding the `/workspace/:id` route, which materializes the handler on demand via `IWorkspaceLifecycleService.handlerFor`; the Agent scope stays in the Chat view's right dock (`src/components/RightPanel.tsx`) across two tabs: the `Agent` tab (`Inspector`: agent switcher + a Plan lookup card — `PlanCard` in `src/components/Inspector.tsx` — querying `GET /sessions/{id}/transcript/plan` (one tool_call_id, or every plan of the agent) via `src/transcript/api.ts`'s `fetchTranscriptPlan` — plus the agent Service panels) and the `State` tab (every key an Agent Service registered into the agent-state container, polled live via `IAgentStateService.snapshot()` — the same live diff-tree view as the session State tab, sharing `StateCard` from `src/components/StateCard.tsx`), while the Session scope has its own column right next to the session-list sidebar (`src/components/SessionPane.tsx`) with two tabs: Services (the pending-interactions card — `src/components/InteractionsCard.tsx` — plus the session Service panels) and State (every key a Session Service registered into the session-state container, read on demand via `ISessionStateService.snapshot()`)). Expanding a Model opens the model inspector inside that view: provider/model config layers plus the resolved runtime view with per-value provenance (config / override / builtin / env / synthesized), served on demand by `IModelCatalog.inspect` — the same resolution pass the runtime's `get` serves, traced via `ResolutionTraceCollector` and assembled by `kosong/model/inspection.ts`. Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `ProxyChannel` model — service-bound `IChannel`, HTTP `ProxyChannel` for calls routed to `/api/v1/debug`), typed by `agent-core-v2` Service interfaces; `GET /api/v1/debug/channels` loads the whole wire protocol 1:1 (every scoped Service, no whitelist). There is no Service-event push channel: panels fetch/refresh on demand (`Sidebar` polls react-query on a 15 s interval), and a connection failure shows a blocking "Debug surface unavailable" screen instead of falling back anywhere. Session-level coarse status is the one exception: `src/activity/` holds a second `/api/v1/ws` client (`GlobalEventsWs`) that subscribes to nothing and consumes the server-pushed global facts — `event.session.work_changed` updates a per-session activity map (`SessionActivityHub` + subscribe/version store, seeded on connect/reconnect from `GET /api/v1/sessions`), while `event.session.created` / `session.meta.updated` invalidate the `['sessions']` query; the `Sidebar` session rows render `running` / `approval` / `question` / `failed` badges from it via `useSessionActivities`. The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`) and exposes `GET /__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local kap-server instance registry (`~/.kimi-code/server/instances` + legacy `lock`) and the home token so the app can zero-config auto-connect and switch servers from the header dropdown at runtime. The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **transcript** surface instead of context memory and carries an in-chat search bar (`src/components/ChatSearchBar.tsx`): it searches the current session via `POST /api/v1/search` with `container: { session_id }` (usually served by the live route, since selecting a session resumes it), and a result click funnels through the app shell's `openSearchHit` — the same agent-switch + `ChatJump` (page-back, scroll, flash) path the global search view uses; full state is read from `GET /api/v1/sessions/{id}/transcript` (initial load = newest page, refreshes re-read from the tail backwards), older history auto-pages with `before_turn` via an IntersectionObserver sentinel at the top of the scroll view, and each timeline item is wrapped in `content-visibility: auto` + `contain-intrinsic-size` so the browser virtualizes off-screen rendering natively (no windowing library); `/api/v1/ws` is an incremental channel (`transcript.ops`, grade `block` — the cheapest grade that still carries whole-state frame upserts, dropping per-token `append` frames; `transcript.reset` is ignored by the store, surfaced only to the audit recorder via the optional `onReset` handler). The channel tracks the op-batch watermark: a dedicated `subscribe_v2` control frame carries the per-agent grades and the `transcript_since` cursor, a seq gap / reconnect / `resync_required` / append gap triggers a point-to-point catch-up (`fetchTranscriptOps` → `GET .../transcript/ops?since_seq=`), and any legacy/incomplete answer falls back to the full REST refresh. Convergence reuses `@moonshot-ai/transcript`'s L2 reducer (`src/transcript/`: REST/WS clients + store; the data model and reducer come from the package, nothing is re-implemented locally). The Transcript audit panel (`src/components/audit/`, the `Audit` tab of the chat view's right dock — `src/components/RightPanel.tsx`, fed the trail by `ChatView`'s `onTrailChange`) replays how the visible store was built: an `AuditTrail` (`src/audit/`) records every step — each REST page (request + replace/prepend), every WS frame (`transcript.ops` live/buffered/flushed/catchup, `transcript.reset`), loss signals, and prompt/cancel actions — with the resulting immutable `AgentState` per entry; the panel offers a draggable timeline plus a Diff tab (structural diff vs the previous entry: added/modified/removed colored, long strings tail-truncated, all fields kept), a full State view, and the raw Event payload. - `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities. +- `packages/agent-core-v2`: the DI × Scope agent engine (the v2 port behind kap-server). Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (`_base/di/scope.ts`). The `workspace/` domain owns one Workspace scope per materialized workspace handler: the App-scope `IWorkspaceLifecycleService` keeps the live handler registry (create-or-get + join, handlers never closed), and each handler's `ISessionLifecycleService` owns session create/resume/fork/close as its child scopes — there is no App-level session lifecycle facade, callers compose `ISessionIndex` → `handlerFor` → the handler. Workspace-scope services hold the handler-shared resources loaded once per handler and refreshed by fs watch: skills / AGENTS.md (`workspaceSkillCatalog` / `workspaceInstructions`), the workspace agent-profile loader (`workspaceAgentProfileLoader` — agent profiles follow the Contribution / Registry / Catalog extension point: the domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit 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 App-scope `builtinAgentProfileLoader` contributes the code-defined profiles, and each Session-scope `sessionAgentProfileCatalog` projects the registry directly (name-level dedup + builtin-override rule in the projection), seeded with only the workspace key), one shared MCP connection set (`workspaceMcp`, pure connection orchestration over the scope-agnostic `mcpCore` layer; the effective server set is owned by `workspaceMcpConfig` — mcp.json files + plugin contributions, fs-watch refreshed — and MCP persistence, the `[mcp]` config section plus OAuth credentials, lives in `app/mcpConfig`, the same wrapper shape as `kosongConfig` over kosong), fs / fs-watch / process runner / git (`workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit`), the additional-directory set (`workspaceDirs`, backed by `.kimi-code/local.toml`), the os-level tool veto (`workspaceToolPolicy`), and the trust marker (`workspaceTrust` — persisted under the home, keyed by `encodeWorkDirKey(root)`; while a workspace is untrusted, `workspaceMcpConfig` skips the project-level `.mcp.json` / `.kimi-code/mcp.json` files, and the state flips through kap-server's `GET|POST /workspaces/{id}/trust` + `POST /workspaces/{id}/untrust` routes). Session/Agent scopes consume these through session-domain seed contracts with change events (`session/mcp`, `session/workspaceInfo`, `session/sessionSkillCatalog` data, …). See `packages/agent-core-v2/AGENTS.md` and use the `agent-core-dev` skill (`.agents/skills/agent-core-dev/SKILL.md`) when developing here. - `packages/node-sdk`: the public TypeScript SDK and harness. - `packages/kosong`: the LLM / provider abstraction layer. - `packages/kaos`: the execution environment and file/process abstractions. - `packages/oauth`: Kimi OAuth and managed auth utilities. - `packages/telemetry`: shared client-side telemetry infrastructure. - `packages/transcript`: the isomorphic transcript rendering data layer — agent-granular L1 store, idempotent L2 operations, `off/turn/block/delta` L3 subscription granularity, framework-free L4 view registry, and turn-cursor pagination. Pure TypeScript (browser-safe, no engine imports) and the sole owner of all transcript contract types (`src/contract/`); consumed by `packages/kap-server` (engine events → transcript, REST + WS surface; live stores backfill history from the persisted per-agent wire records — main on first attach, any agent on demand, cold sessions rebuild any agent — with 0-based turn ordinals matching the engine's). The cold rebuild is a two-level fold over `wire.jsonl` as the single source of truth: `history/groupTurns.ts` (context messages → turn tree) plus `history/foldFacts.ts` (non-context records → tasks, interactions, todos, goal/plan/swarm meta, and end-appended markers/taskrefs; interactions left pending at shutdown fold to `cancelled`). Plan content is a recorded fact too: each ExitPlanMode review submission offloads the document to `agents//plan//v.md` and persists a reference-only `plan.revision` record (`{id, version, path, sha256, bytes}`), which projects — live and cold — to a `plan.revision` marker and the `modes.plan` badge (`{reviewPath, version}`). It also owns the op-batch sequencing contract (`transcriptSeqSchema` in `contract/schema.ts`): a per-(session, agent) monotonic batch `seq` on `transcript.ops` / `transcript.reset` / the REST transcript response, the `transcript_since` subscription cursor, and the `GET .../transcript/ops` catch-up response shape — every field optional so pre-seq peers fall back to loss-signal-driven refreshes. Beyond the timeline, the model carries wire-equivalent detail: steps carry `usage` / `finishReason` / `timing` (LLM latencies) / `retry` / interrupt reason, turns carry `durationMs` / `error` / `usage`, tool frames carry the streamed `inputText` and the latest `progress`, tasks carry subagent `resultSummary` / `error` / `stateReason` / `usage`, `meta.agent` mirrors the agent status slices (model / usage / context / permission / phase), a global `prompts` entity (op `prompt.upsert`) tracks the prompt queue, and `hook.result` lands as a `'hook'` marker. These live-projected fields are NOT backfilled by the cold rebuild (known limitation). -- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2`). Exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Its transcript surface implements the op-batch sequencing contract: `TranscriptService.dispatchOps` assigns every dispatched batch a per-agent consecutive `seq` and retains it in a bounded in-memory journal (`TRANSCRIPT_OPS_JOURNAL_CAPACITY`, dies with the live store); WS `transcript.ops`/`transcript.reset` payloads carry the seq/watermark, a `transcript_since` subscription cursor (carried, with the per-agent grades, by the `subscribe_v2` control frame — the only transcript subscription channel; its agent-grained counterpart `unsubscribe_v2` detaches listed agents' streams, or the whole session's when `agent_ids` is absent, letting the detached agents' legacy events flow again) replays journaled batches instead of a baseline reset when the journal covers it, and `GET /sessions/{id}/transcript/ops?since_seq=` serves point-to-point catch-up (`complete: false` = journal can't cover or session cold → caller falls back to a full refresh). Beside the paged route, `GET /sessions/{id}/transcript/plan?agent_id=[&tool_call_id=]` projects an agent's ExitPlanMode plan info (content / path / options / review outcome; `tool_call_id` narrows to one call, omitted lists every recoverable plan) from the first available fact — the linked approval interaction's persisted request display, the live tool frame's display, or the tool result output text. The baseline `transcript.reset` itself is items-empty (`TRANSCRIPT_RESET_TAIL_TURNS = 0`): it carries only global state + the watermark + `has_more_older`, because history always pages in over REST. When a WS connection subscribes to the transcript protocol (grade ≠ `off` for an agent), the broadcaster suppresses the transcript-projected `session_event` types for that connection × agent (`TRANSCRIPT_PROJECTED_EVENT_TYPES` + `suppressedByTranscript` in `sessionEventBroadcaster.ts`; cursor replay via `getBufferedSince` applies the same filter). Suppression is only a per-connection send view — the journal still records everything, and connections without transcript grades are unaffected. The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. The global search surface is `POST /api/v1/search` (`src/search/` + `src/routes/search.ts`): a cross-session full-text search over user messages, assistant text, and session titles, backed by a single minidb database at `/search-index` (`IGlobalSearchService`, App scope — the write-lock holder is the indexer, other processes open read-only and catch up via WAL). It serves two modes: `terms` (the default — minidb's inverted text index over ASCII words + CJK uni/bigrams, no positions, term-level AND) and `literal` (substring-exact search: a hashed 2/3-gram index supplies candidates, every candidate's text is then confirmed with `includes`, so hits carry zero false positives; literal ignores `sort` and returns newest-first, and a candidate set truncated at `LITERAL_CANDIDATE_CAP` is flagged `incomplete: 'candidate_cap'`). When `container.session_id` is provided and that session is live in this process (`TranscriptService.forSessionLive` returns a store, wired via `setLiveTranscriptSource` in `start.ts`), BOTH modes instead scan the in-memory transcript store (turn prompts + assistant text frames, history established via `whenReady`/`ensureAgentHistory`) — no index involved; terms-mode live hits are scored Σ log(1+tf) (comparable only within a route, per the `GlobalSearchSource` contract), live-route errors never fall back to the index, and the response's `source: 'live' | 'index'` field (also mixed into the page-token fingerprint, so a mid-pagination route flip invalidates the old token) tells the caller which route served the page. +- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2` — four scopes, App/Workspace/Session/Agent; session create/resume/fork routes compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler's `ISessionLifecycleService`, and the fs routes resolve session → handler → the Workspace-scope fs services, with one exception: `fs:search` also accepts a workspace reference (registered id or absolute root) in the `{session_id}` slot, so a not-yet-created draft session's `@` file mention resolves the workspace handler directly; the first-class session-less form is `POST /api/v1/workspace/fs:search` (the workspace reference travels in the body)). Exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist, Workspace scope addressable alongside App/Session/Agent; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Its transcript surface implements the op-batch sequencing contract: `TranscriptService.dispatchOps` assigns every dispatched batch a per-agent consecutive `seq` and retains it in a bounded in-memory journal (`TRANSCRIPT_OPS_JOURNAL_CAPACITY`, dies with the live store); WS `transcript.ops`/`transcript.reset` payloads carry the seq/watermark, a `transcript_since` subscription cursor (carried, with the per-agent grades, by the `subscribe_v2` control frame — the only transcript subscription channel; its agent-grained counterpart `unsubscribe_v2` detaches listed agents' streams, or the whole session's when `agent_ids` is absent, letting the detached agents' legacy events flow again) replays journaled batches instead of a baseline reset when the journal covers it, and `GET /sessions/{id}/transcript/ops?since_seq=` serves point-to-point catch-up (`complete: false` = journal can't cover or session cold → caller falls back to a full refresh). Beside the paged route, `GET /sessions/{id}/transcript/plan?agent_id=[&tool_call_id=]` projects an agent's ExitPlanMode plan info (content / path / options / review outcome; `tool_call_id` narrows to one call, omitted lists every recoverable plan) from the first available fact — the linked approval interaction's persisted request display, the live tool frame's display, or the tool result output text. The baseline `transcript.reset` itself is items-empty (`TRANSCRIPT_RESET_TAIL_TURNS = 0`): it carries only global state + the watermark + `has_more_older`, because history always pages in over REST. When a WS connection subscribes to the transcript protocol (grade ≠ `off` for an agent), the broadcaster suppresses the transcript-projected `session_event` types for that connection × agent (`TRANSCRIPT_PROJECTED_EVENT_TYPES` + `suppressedByTranscript` in `sessionEventBroadcaster.ts`; cursor replay via `getBufferedSince` applies the same filter). Suppression is only a per-connection send view — the journal still records everything, and connections without transcript grades are unaffected. The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. The global search surface is `POST /api/v1/search` (`src/search/` + `src/routes/search.ts`): a cross-session full-text search over user messages, assistant text, and session titles, backed by a single minidb database at `/search-index` (`IGlobalSearchService`, App scope — the write-lock holder is the indexer, other processes open read-only and catch up via WAL). It serves two modes: `terms` (the default — minidb's inverted text index over ASCII words + CJK uni/bigrams, no positions, term-level AND) and `literal` (substring-exact search: a hashed 2/3-gram index supplies candidates, every candidate's text is then confirmed with `includes`, so hits carry zero false positives; literal ignores `sort` and returns newest-first, and a candidate set truncated at `LITERAL_CANDIDATE_CAP` is flagged `incomplete: 'candidate_cap'`). When `container.session_id` is provided and that session is live in this process (`TranscriptService.forSessionLive` returns a store, wired via `setLiveTranscriptSource` in `start.ts`), BOTH modes instead scan the in-memory transcript store (turn prompts + assistant text frames, history established via `whenReady`/`ensureAgentHistory`) — no index involved; terms-mode live hits are scored Σ log(1+tf) (comparable only within a route, per the `GlobalSearchSource` contract), live-route errors never fall back to the index, and the response's `source: 'live' | 'index'` field (also mixed into the page-token fingerprint, so a mid-pagination route flip invalidates the old token) tells the caller which route served the page. - `packages/klient`: the client SDK — a contract-driven facade over agent-core-v2 with aggregated `global.*` / `session(id).*` / `agent(id).*` methods, zod validation on every call, and klient-level typed event forwarding. Transport is chosen once at creation via subpath entry (`@moonshot-ai/klient/ipc|memory`); both return the same `Klient`. The package also hosts the e2e suites: the legacy `/api/v1` live suites (`test/e2e/legacy/`) and the docker e2e runner (`pnpm --filter @moonshot-ai/klient docker:e2e`). See `packages/klient/AGENTS.md`. - `packages/server-e2e`: live e2e tests and scenarios against a running server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). See `packages/server-e2e/AGENTS.md`. - `packages/tree-sitter-bash`: a pure-TypeScript bash parser (no runtime deps, no wasm) that produces a syntax tree with tree-sitter-bash 0.25.0 named-node type names and UTF-16 code-unit offsets. `parse(source, { timeoutMs, maxNodes })` runs under a deterministic budget (default 50 ms / 50k nodes, plus per-chain recursion depth caps) and returns a discriminated `ParseResult` (`{ ok, rootNode, hasError }` or `{ ok: false, reason: 'aborted' }`) — callers must treat aborted/hasError trees as "cannot analyze" and degrade. Parser only, no safety judgments; consumers (e.g. Bash tool permission matching) live elsewhere. Known deviations from the reference are tracked in the package README's "Known differences" section, pinned by differential fixtures tested against the real `tree-sitter-bash` wasm (dev-only). diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 4b62763837..479eefd700 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,25 @@ # @moonshot-ai/kimi-code +## 0.31.1 + +### Patch Changes + +- [#2410](https://github.com/MoonshotAI/kimi-code/pull/2410) [`f1a3475`](https://github.com/MoonshotAI/kimi-code/commit/f1a3475ad5d6540447496701aa75fd4b035ecb28) Thanks [@sailist](https://github.com/sailist)! - 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. + +- [#2400](https://github.com/MoonshotAI/kimi-code/pull/2400) [`1f3f5da`](https://github.com/MoonshotAI/kimi-code/commit/1f3f5dadaaa4a1d705cc98aee1dbbef13680502c) Thanks [@7Sageer](https://github.com/7Sageer)! - Preserve the assistant's partial output when a turn is interrupted with Esc, and remind the model that the previous turn was deliberately interrupted. + +- [#2415](https://github.com/MoonshotAI/kimi-code/pull/2415) [`5c0ec29`](https://github.com/MoonshotAI/kimi-code/commit/5c0ec2938ac3a01624b6503e5e5df80c9b08f46a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Enable Monaco-based highlighting for code blocks, and fix line numbers overlapping or drifting out of alignment in fallback-rendered code blocks. + +- [#2442](https://github.com/MoonshotAI/kimi-code/pull/2442) [`bb2919e`](https://github.com/MoonshotAI/kimi-code/commit/bb2919eb818a6cb51c71cbabf1bac9020131bce7) Thanks [@liruifengv](https://github.com/liruifengv)! - Reduce frequent full-screen redraws in the TUI. + +- [#2125](https://github.com/MoonshotAI/kimi-code/pull/2125) [`e111c87`](https://github.com/MoonshotAI/kimi-code/commit/e111c878fd5cd07994e125b9e4e07e4069f01be1) Thanks [@bowenliang123](https://github.com/bowenliang123)! - web: Order permission modes from safest to most permissive across settings surfaces, and fix the swapped yolo/auto risk colors in the status panel and mobile settings. + +- [#2459](https://github.com/MoonshotAI/kimi-code/pull/2459) [`326e1fb`](https://github.com/MoonshotAI/kimi-code/commit/326e1fb6ce59fbf2d6c7646e6d587759565814fd) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix chat code blocks rendering in the proportional UI font at the wrong size after the markdown renderer upgrade, and align the loading fallback with the highlighted block so the upgrade no longer shifts layout. + +- [#2437](https://github.com/MoonshotAI/kimi-code/pull/2437) [`ed7a4cc`](https://github.com/MoonshotAI/kimi-code/commit/ed7a4cc095e1619e4dbb6c2c77c89a52e312b085) Thanks [@sailist](https://github.com/sailist)! - web: Make the @ file mention work in a new-session draft, before the first prompt creates the session. + +- [#2437](https://github.com/MoonshotAI/kimi-code/pull/2437) [`ed7a4cc`](https://github.com/MoonshotAI/kimi-code/commit/ed7a4cc095e1619e4dbb6c2c77c89a52e312b085) Thanks [@sailist](https://github.com/sailist)! - web: Fix new sessions showing the thinking level (e.g. Max) while the first message actually ran with thinking off. + ## 0.31.0 ### Minor Changes diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index f78eac615b..8ac3ff0f76 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code", - "version": "0.31.0", + "version": "0.31.1", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 220aa749ff..84ad5897bb 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -84,7 +84,8 @@ export async function runShell( // Experimental agent-core-v2 route (same master switch as `kimi -p`): the // harness is the SDK's v2-backed client, so the whole TUI runs on the // agent-core-v2 engine. - const harness = isKimiV2Enabled() + const engineV2 = isKimiV2Enabled(); + const harness = engineV2 ? createKimiHarnessV2(harnessOptions) : createKimiHarness(harnessOptions); log.info('kimi-code starting', { @@ -124,6 +125,7 @@ export async function runShell( startupNotice: configWarning, migrationPlan, migrateOnly: runOptions.migrateOnly, + engineV2, }); initializeCliTelemetry({ diff --git a/apps/kimi-code/src/cli/sub/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts index 3ade36aa42..bb61b0add3 100644 --- a/apps/kimi-code/src/cli/sub/provider.ts +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -25,7 +25,6 @@ import { CatalogFetchError, createKimiHarness, DEFAULT_CATALOG_URL, - fetchCatalog, resolveCatalogImport, type Catalog, type CatalogProviderEntry, @@ -35,6 +34,7 @@ import { import type { Command } from 'commander'; import { createKimiCodeHostIdentity, createKimiCodeUserAgent } from '#/cli/version'; +import { fetchCatalogOrBuiltIn } from '#/utils/catalog-fetch'; interface WritableLike { write(chunk: string): boolean; @@ -434,7 +434,13 @@ export async function handleCatalogAdd( async function loadCatalogOrExit(deps: ProviderDeps, url: string): Promise { try { - return await fetchCatalog(url, { userAgent: createKimiCodeUserAgent() }); + const loaded = await fetchCatalogOrBuiltIn(url, { userAgent: createKimiCodeUserAgent() }); + if (loaded.fromBuiltIn) { + deps.stderr.write( + `Warning: failed to reach ${url}; using the built-in models.dev catalog snapshot.\n`, + ); + } + return loaded.catalog; } catch (error) { const suffix = error instanceof CatalogFetchError ? ` (HTTP ${String(error.status)})` : ''; deps.stderr.write(`Failed to fetch catalog from ${url}${suffix}: ${errorMessage(error)}\n`); diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index 3f8ccbcf70..891032b4d8 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -33,15 +33,15 @@ import { ISessionCronService, ISessionIndex, ISessionLifecycleService, + IWorkspaceLifecycleService, ITelemetryService, PRINT_MAX_TURNS_DEFAULT, PRINT_WAIT_CEILING_S_DEFAULT, - agentCatalogRuntimeOptionsSeed, applyPrintModeConfigDefaults, bootstrap, createCloudAppender, ensureMainAgent, - hostRequestHeadersSeed, + resumeSessionById, logSeed, parseAgentFileText, resolveAgentPath, @@ -49,7 +49,6 @@ import { resolveKimiHome, resolveLoggingConfig, resolvePrintBackgroundMode, - skillCatalogRuntimeOptionsSeed, type DomainEvent, type IAgentScopeHandle, type ISessionScopeHandle, @@ -128,18 +127,24 @@ export async function runV2Print( const identity = createKimiCodeHostIdentity(version); const hostHeaders = createKimiDefaultHeaders({ homeDir, ...identity }); - const { app } = bootstrap({ homeDir, clientIdentity: identity }, [ - ...logSeed(logging), - ...hostRequestHeadersSeed(hostHeaders), - // `--skillsDir` (v1 print parity): explicit skill dirs replace default - // user / project discovery for this process. - ...skillCatalogRuntimeOptionsSeed(opts.skillsDirs), - // `--agent-file`: explicit agent definition files, registered with the - // highest-precedence source for this process. Passed through unresolved — - // the engine expands `~` and resolves relative paths against the session - // workDir (mirroring `--skills-dir`). - ...agentCatalogRuntimeOptionsSeed(opts.agentFiles), - ]); + const { app } = bootstrap( + { + homeDir, + clientIdentity: identity, + args: { + requestHeaders: hostHeaders, + // `--skillsDir` (v1 print parity): explicit skill dirs replace default + // user / project discovery for this process. + skillDirs: opts.skillsDirs, + // `--agent-file`: explicit agent definition files, registered with the + // highest-precedence source for this process. Passed through unresolved — + // the engine expands `~` and resolves relative paths against the session + // workDir (mirroring `--skills-dir`). + agentFiles: opts.agentFiles, + }, + }, + [...logSeed(logging)], + ); const auth = app.accessor.get(IOAuthToolkit); const configService = app.accessor.get(IConfigService); @@ -256,7 +261,7 @@ async function resolveNativeSession( defaultModel: string | undefined, stderr: PromptOutput, ): Promise { - 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 +309,7 @@ async function resolveNativeSession( }; const resumeById = async (id: string): Promise => { - 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 +379,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(ISessionLifecycleService).create({ workDir, additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, mainAgentBinding: { diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index 23bef02ba1..dbfbddfcb2 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -9,13 +9,13 @@ import { catalogProviderModels, CatalogFetchError, DEFAULT_CATALOG_URL, - fetchCatalog, resolveCatalogImport, type Catalog, type ThinkingEffort, } from '@moonshot-ai/kimi-code-sdk'; import { createKimiCodeUserAgent } from '#/cli/version'; +import { fetchCatalogOrBuiltIn } from '#/utils/catalog-fetch'; import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; import { CustomRegistryImportDialogComponent, @@ -162,11 +162,17 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { const spinner = host.showLoginProgressSpinner(`Fetching catalog from ${DEFAULT_CATALOG_URL}`); let catalog: Catalog | undefined; try { - catalog = await fetchCatalog(DEFAULT_CATALOG_URL, { + const loaded = await fetchCatalogOrBuiltIn(DEFAULT_CATALOG_URL, { signal: controller.signal, userAgent: createKimiCodeUserAgent(), }); - spinner.stop({ ok: true, label: 'Catalog loaded.' }); + catalog = loaded.catalog; + spinner.stop({ + ok: true, + label: loaded.fromBuiltIn + ? 'Catalog loaded from built-in snapshot (models.dev unreachable).' + : 'Catalog loaded.', + }); } catch (error) { if (controller.signal.aborted) { spinner.stop({ ok: false, label: 'Aborted.' }); diff --git a/apps/kimi-code/src/tui/components/dialogs/trust-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/trust-prompt.ts new file mode 100644 index 0000000000..0ecca37325 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/trust-prompt.ts @@ -0,0 +1,107 @@ +import { + Key, + matchesKey, + truncateToWidth, + wrapTextWithAnsi, + type Component, + type Focusable, +} from '@moonshot-ai/pi-tui'; + +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; + +export type TrustPromptChoice = 'trust' | 'distrust'; + +export interface TrustPromptOptions { + readonly workDir: string; + /** Project-level MCP servers that trusting would enable; may be empty. */ + readonly gatedMcpServers: readonly string[]; + /** Esc resolves to 'distrust' as well. */ + readonly onSelect: (choice: TrustPromptChoice) => void; +} + +interface TrustPromptOption { + readonly value: TrustPromptChoice; + readonly label: string; + readonly description: string; +} + +const OPTIONS: readonly TrustPromptOption[] = [ + { + value: 'trust', + label: 'Trust this folder', + description: 'Enable project MCP servers. Remembered for this folder.', + }, + { + value: 'distrust', + label: "Don't trust", + description: 'Exit Kimi Code. Asked again next launch.', + }, +]; + +export class TrustPromptComponent implements Component, Focusable { + focused = false; + private selectedIndex = 0; + + constructor(private readonly opts: TrustPromptOptions) {} + + invalidate(): void {} + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onSelect('distrust'); + return; + } + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.min(OPTIONS.length - 1, this.selectedIndex + 1); + return; + } + if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) { + this.opts.onSelect(OPTIONS[this.selectedIndex]!.value); + } + } + + render(width: number): string[] { + const rule = currentTheme.fg('primary', '─'.repeat(width)); + const lines = [ + rule, + currentTheme.boldFg('primary', ' Trust this folder?'), + currentTheme.fg('textMuted', ' ↑↓ navigate · Enter select · Esc exit'), + '', + ...wrapTextWithAnsi(this.opts.workDir, Math.max(20, width - 2)).map( + (line) => ` ${currentTheme.fg('textStrong', line)}`, + ), + '', + ]; + + const notice = + this.opts.gatedMcpServers.length > 0 + ? `Kimi Code loads project-level MCP servers (.mcp.json, .kimi-code/mcp.json) only in trusted folders. They run as local processes on your machine. This folder defines: ${this.opts.gatedMcpServers.join(', ')}.` + : 'Kimi Code loads project-level MCP servers (.mcp.json, .kimi-code/mcp.json) only in trusted folders. They run as local processes on your machine.'; + for (const line of wrapTextWithAnsi(notice, Math.max(20, width - 2))) { + lines.push(` ${currentTheme.fg('textMuted', line)}`); + } + lines.push(''); + + for (let i = 0; i < OPTIONS.length; i += 1) { + const option = OPTIONS[i]!; + const selected = i === this.selectedIndex; + const pointer = selected ? SELECT_POINTER : ' '; + const label = selected + ? currentTheme.boldFg('primary', option.label) + : currentTheme.fg('text', option.label); + lines.push(currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `) + label); + for (const line of wrapTextWithAnsi(option.description, Math.max(20, width - 4))) { + lines.push(` ${currentTheme.fg('textMuted', line)}`); + } + lines.push(''); + } + + lines.push(rule); + return lines.map((line) => truncateToWidth(line, width)); + } +} diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index b1677e87b2..51958dbe96 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -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 = new Map(); - private autocompleteWasShowing = false; setArgumentHints(hints: ReadonlyMap): 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; diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index bf8699ff2c..06c5f46d83 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -1,4 +1,11 @@ -import type { CreateSessionOptions, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; +import { + removeProviderFromConfig, + type CreateSessionOptions, + type KimiConfig, + type KimiHarness, + type OAuthRef, + type Session, +} from '@moonshot-ai/kimi-code-sdk'; import { createKimiCodeUserAgent } from '#/cli/version'; @@ -7,6 +14,7 @@ import type { SkillListSession } from '../commands'; import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui'; import { refreshAllProviderModels, + type RefreshProviderHost, type RefreshProviderScope, type RefreshResult, } from '../utils/refresh-providers'; @@ -172,23 +180,68 @@ export class AuthFlowController { } private async refreshProviderModelsWithScope(scope: RefreshProviderScope): Promise { + const result = await refreshAllProviderModels(this.buildRefreshHost(), { scope }); + if (result.changed.length > 0) { + await this.refreshAvailableModels(); + } + return result; + } + + /** + * Build the refresh orchestrator's persistence host. When the harness can + * persist several config sections as ONE atomic write (the v2 engine's + * `replaceSections`), the orchestrator's two-phase contract (removeProvider + * then setConfig) is absorbed the same way the v2 engine's own refresh path + * does it: the removal is staged in memory only, and the following + * setConfig persists the complete records in a single write — so a process + * exit mid-refresh can never leave config.toml in a "provider removed, not + * yet restored" state. The v1 harness keeps the legacy host (two + * whole-document writes, each atomic on its own). + */ + private buildRefreshHost(): RefreshProviderHost { const { host } = this; - const result = await refreshAllProviderModels( - { + const resolveOAuthToken = async (providerName: string, oauthRef?: OAuthRef): Promise => { + const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef); + return tokenProvider.getAccessToken(); + }; + const userAgent = createKimiCodeUserAgent(); + if (!host.harness.supportsAtomicSectionReplace()) { + return { getConfig: () => host.harness.getConfig({ reload: true }), removeProvider: (id) => host.harness.removeProvider(id), setConfig: (patch) => host.harness.setConfig(patch), - resolveOAuthToken: async (providerName, oauthRef) => { - const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef); - return tokenProvider.getAccessToken(); - }, - userAgent: createKimiCodeUserAgent(), - }, - { scope }, - ); - if (result.changed.length > 0) { - await this.refreshAvailableModels(); + resolveOAuthToken, + userAgent, + }; } - return result; + let staged: KimiConfig | undefined; + const requireStaged = (): KimiConfig => { + if (staged === undefined) { + throw new Error('refresh host: getConfig must be called before writes'); + } + return staged; + }; + return { + getConfig: async () => { + staged = await host.harness.getConfig({ reload: true }); + return staged; + }, + removeProvider: (id) => { + staged = removeProviderFromConfig(requireStaged(), id); + return Promise.resolve(staged); + }, + setConfig: async (patch) => { + // The orchestrator always passes complete records (built from a full + // clone), so the Partial-shaped patch is a full KimiConfig overlay. + staged = { ...requireStaged(), ...patch } as KimiConfig; + // Object.entries keeps keys whose value is `undefined`, so a cleared + // section (e.g. a dangling defaultModel) is expressed as a removal in + // the atomic write; sections absent from the patch stay untouched. + await host.harness.replaceConfigSections(Object.fromEntries(Object.entries(patch))); + return staged; + }, + resolveOAuthToken, + userAgent, + }; } } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 79c9f1d7d7..834bd77771 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -12,6 +12,7 @@ import type { PermissionMode, PromptPart, Session, + WorkspaceTrustInfo, } from '@moonshot-ai/kimi-code-sdk'; import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; import { @@ -64,6 +65,7 @@ import { CompactionComponent } from './components/dialogs/compaction'; import { HelpPanelComponent } from './components/dialogs/help-panel'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; +import { TrustPromptComponent, type TrustPromptChoice } from './components/dialogs/trust-prompt'; import { FileMentionProvider, type SlashAutocompleteCommand, @@ -185,6 +187,8 @@ export interface KimiTUIStartupInput { readonly migrationPlan?: MigrationPlan | null; /** When true, run only the migration screen, then exit (the `kimi migrate` command). */ readonly migrateOnly?: boolean; + /** agent-core-v2 engine (KIMI_CODE_EXPERIMENTAL_FLAG); enables the startup workspace-trust prompt. */ + readonly engineV2?: boolean; } type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; @@ -321,8 +325,10 @@ export class KimiTUI { private uninstallRainbowDance: () => void; private signalCleanupHandlers: Array<() => void> = []; private isShuttingDown = false; + private backgroundRefreshPromise: Promise | undefined; private readonly migrationPlan: MigrationPlan | null; private readonly migrateOnly: boolean; + private readonly engineV2: boolean; private startupNotice: string | undefined; private lastActivityMode: string | undefined; private currentLoadingTip: { kind: LoadingTipKind; tip: string | undefined } | undefined = @@ -396,6 +402,7 @@ export class KimiTUI { this.options = tuiOptions; this.migrationPlan = startupInput.migrationPlan ?? null; this.migrateOnly = startupInput.migrateOnly ?? false; + this.engineV2 = startupInput.engineV2 ?? false; this.startupNotice = startupInput.startupNotice; this.state = createTUIState(tuiOptions); this.uninstallRainbowDance = installRainbowDance(() => { @@ -555,8 +562,13 @@ export class KimiTUI { return; } + const trustPromptStartedLoop = await this.maybeRunWorkspaceTrustPrompt(); const shouldReplayHistory = await this.initMainTui(); - this.startEventLoop(); + // When the trust prompt already started the event loop, starting it + // again would re-run pi-tui's terminal.start() — stacking a second + // Kitty keyboard-protocol push (leaking CSI-u mode past exit) and + // duplicate stdin listeners. + if (!trustPromptStartedLoop) this.startEventLoop(); try { this.startBackgroundFdAutocomplete(); await this.finishStartup(shouldReplayHistory); @@ -737,7 +749,7 @@ export class KimiTUI { private async init(): Promise { setExperimentalFeatures(await this.harness.getExperimentalFeatures()); await this.authFlow.refreshAvailableModels(); - void this.refreshProviderModelsInBackground(); + this.backgroundRefreshPromise = this.refreshProviderModelsInBackground(); const { startup } = this.options; const { workDir } = this.state.appState; @@ -841,6 +853,16 @@ export class KimiTUI { this.isShuttingDown = true; this.unregisterSignalHandlers(); this.aborted = true; + // Give the startup provider-model refresh a brief chance to finish before + // the harness closes (and the process exits): its config writes are each + // atomic, so draining can only ever leave a complete file behind. Bounded + // so a slow network never delays the exit. + if (this.backgroundRefreshPromise !== undefined) { + await Promise.race([ + this.backgroundRefreshPromise, + new Promise((resolve) => setTimeout(resolve, 1500)), + ]); + } this.streamingUI.discardPending(); // Stop background polling, streaming intervals, and per-component timers // before tearing the UI down, so they can't keep firing requestRender after @@ -2052,11 +2074,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 +2571,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 +2812,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 { @@ -2843,6 +2854,57 @@ export class KimiTUI { return result; } + /** + * agent-core-v2 startup gate: before any session is created, ask whether to + * trust this folder when the workspace is not trusted yet (project-level MCP + * servers stay disabled while untrusted). Best-effort throughout — a failed + * check or trust write never blocks startup. Choosing "don't trust" (or Esc) + * exits the program before any session is created; the prompt reappears on + * the next launch: the engine's untrusted state is indistinguishable from + * never-trusted. Returns true when the prompt started the event loop (the + * caller must not start it again). + */ + private async maybeRunWorkspaceTrustPrompt(): Promise { + if (!this.engineV2) return false; + const workDir = this.state.appState.workDir; + let info: WorkspaceTrustInfo; + try { + info = await this.harness.getWorkspaceTrustInfo(workDir); + } catch { + return false; + } + if (info.trusted) return false; + this.startEventLoop(); + const choice = await new Promise((resolve) => { + this.state.activeDialog = 'trust-prompt'; + this.mountEditorReplacement( + new TrustPromptComponent({ + workDir, + gatedMcpServers: info.gatedMcpServers, + onSelect: (c) => { + resolve(c); + }, + }), + ); + }); + this.state.activeDialog = null; + if (choice !== 'trust') { + // Declining trust exits the program (Claude Code's "No, exit" semantics): + // stop() runs the standard shutdown path and ends in process.exit. The + // editor is NOT restored first — its frame would linger as an orphaned + // input box above the exit message; the prompt stays as the last frame. + await this.stop(); + return true; + } + this.restoreEditor(); + try { + await this.harness.trustWorkspace(workDir); + } catch { + // A failed write leaves the workspace untrusted (re-asked next launch). + } + return true; + } + showHelpPanel(): void { this.state.activeDialog = 'help'; this.mountEditorReplacement( diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index d9665c9e3d..34571eb364 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -48,7 +48,7 @@ export interface TUIState { sessions: SessionRow[]; loadingSessions: boolean; sessionsScope: 'cwd' | 'all'; - activeDialog: 'session-picker' | 'help' | null; + activeDialog: 'session-picker' | 'help' | 'trust-prompt' | null; tasksBrowser: TasksBrowserState | undefined; externalEditorRunning: boolean; queuedMessages: QueuedMessage[]; diff --git a/apps/kimi-code/src/utils/catalog-fetch.ts b/apps/kimi-code/src/utils/catalog-fetch.ts new file mode 100644 index 0000000000..0f3ffdddfb --- /dev/null +++ b/apps/kimi-code/src/utils/catalog-fetch.ts @@ -0,0 +1,57 @@ +import { + DEFAULT_CATALOG_URL, + fetchCatalog, + loadBuiltInCatalog, + type Catalog, + type FetchCatalogOptions, +} from '@moonshot-ai/kimi-code-sdk'; + +import { BUILT_IN_CATALOG_JSON } from '#/built-in-catalog'; + +export interface FetchCatalogOrBuiltInResult { + readonly catalog: Catalog; + /** True when the network fetch failed and the release-build snapshot was used. */ + readonly fromBuiltIn: boolean; +} + +export interface FetchCatalogOrBuiltInOptions extends FetchCatalogOptions { + /** + * Override the built-in snapshot JSON (tests). Defaults to the tsdown-injected + * `__KIMI_CODE_BUILT_IN_CATALOG__` constant. + */ + readonly builtInJson?: string; +} + +/** + * Fetches a models.dev-style catalog, falling back to the release-build + * snapshot when the public default URL is unreachable. + * + * Custom `--url` overrides never fall back — a private registry must fail + * loudly rather than silently substitute models.dev. User abort + * (`signal.aborted`) also skips the fallback so Cancel stays Cancel. + */ +export async function fetchCatalogOrBuiltIn( + url: string, + options: FetchCatalogOrBuiltInOptions = {}, +): Promise { + try { + const catalog = await fetchCatalog(url, options); + return { catalog, fromBuiltIn: false }; + } catch (error) { + if (options.signal?.aborted) throw error; + if (isAbortError(error)) throw error; + if (url !== DEFAULT_CATALOG_URL) throw error; + const builtIn = loadBuiltInCatalog(options.builtInJson ?? BUILT_IN_CATALOG_JSON); + if (builtIn === undefined) throw error; + return { catalog: builtIn, fromBuiltIn: true }; + } +} + +function isAbortError(error: unknown): boolean { + return ( + (typeof DOMException !== 'undefined' && + error instanceof DOMException && + error.name === 'AbortError') || + (error instanceof Error && error.name === 'AbortError') + ); +} diff --git a/apps/kimi-code/test/cli/v2-run-print.test.ts b/apps/kimi-code/test/cli/v2-run-print.test.ts index 27c6643898..44d3390d8c 100644 --- a/apps/kimi-code/test/cli/v2-run-print.test.ts +++ b/apps/kimi-code/test/cli/v2-run-print.test.ts @@ -5,7 +5,6 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - IAgentCatalogRuntimeOptions, IAgentGoalService, IAgentLifecycleService, IAgentPermissionModeService, @@ -21,10 +20,10 @@ import { ISessionCronService, ISessionIndex, ISessionLifecycleService, - ISkillCatalogRuntimeOptions, + IWorkspaceLifecycleService, ITelemetryService, + type BootstrapInput, type DomainEvent, - type ScopeSeed, } from '@moonshot-ai/agent-core-v2'; import { runV2Print } from '../../src/cli/v2/run-v2-print'; @@ -179,6 +178,17 @@ function makeFakeHarness() { ]); const session = fakeScope('ses_v2', sessionServices); + const handlerServices = new Map([ + [ + ISessionLifecycleService, + { + create: vi.fn(async () => session), + resume: vi.fn(async () => session), + }, + ], + ]); + const workspace = fakeScope('wd_v2', handlerServices); + const appServices = new Map([ [ IConfigService, @@ -193,13 +203,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 +254,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', () => { @@ -271,7 +293,7 @@ describe('runV2Print', () => { expect(app.dispose).toHaveBeenCalled(); }); - it('seeds explicit skill dirs from --skillsDir into bootstrap', async () => { + it('passes explicit skill dirs from --skillsDir into bootstrap args', async () => { const stdout = writer(); const stderr = writer(); const { app, agent } = makeFakeHarness(); @@ -284,12 +306,11 @@ describe('runV2Print', () => { stderr, }); - const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed; - const seeded = seeds.find(([id]) => id === ISkillCatalogRuntimeOptions); - expect(seeded?.[1]).toMatchObject({ explicitDirs: ['/skills'] }); + const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput; + expect(input.args?.skillDirs).toEqual(['/skills']); }); - it('leaves the skill runtime options unseeded when --skillsDir is empty', async () => { + it('leaves the skill dirs arg unset when --skillsDir is empty', async () => { const stdout = writer(); const stderr = writer(); const { app, agent } = makeFakeHarness(); @@ -299,14 +320,14 @@ describe('runV2Print', () => { await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr }); - const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed; - expect(seeds.some(([id]) => id === ISkillCatalogRuntimeOptions)).toBe(false); + const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput; + expect(input.args?.skillDirs ?? []).toEqual([]); }); 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); @@ -317,11 +338,10 @@ describe('runV2Print', () => { { stdout, stderr }, ); - const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed; - const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions); - expect(seeded?.[1]).toMatchObject({ explicitFiles: ['/agents/reviewer.md'] }); + const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput; + expect(input.args?.agentFiles).toEqual(['/agents/reviewer.md']); - const lifecycle = appServices.get(ISessionLifecycleService) as { + const lifecycle = handlerServices.get(ISessionLifecycleService) as { create: ReturnType; }; expect(lifecycle.create).toHaveBeenCalledWith({ @@ -342,7 +362,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); @@ -352,11 +372,10 @@ describe('runV2Print', () => { stderr, }); - const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed; - const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions); - expect(seeded?.[1]).toMatchObject({ explicitFiles: [agentFile] }); + const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput; + expect(input.args?.agentFiles).toEqual([agentFile]); - const lifecycle = appServices.get(ISessionLifecycleService) as { + const lifecycle = handlerServices.get(ISessionLifecycleService) as { create: ReturnType; }; expect(lifecycle.create).toHaveBeenCalledWith({ @@ -371,8 +390,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(ISessionLifecycleService) as { create: ReturnType; }; lifecycle.create.mockRejectedValueOnce(new Error('Unknown agent profile')); @@ -406,7 +425,7 @@ describe('runV2Print', () => { expect(profile.bind).not.toHaveBeenCalled(); }); - it('leaves the agent runtime options unseeded when --agentFile is empty', async () => { + it('leaves the agent files arg unset when --agentFile is empty', async () => { const stdout = writer(); const stderr = writer(); const { app, agent } = makeFakeHarness(); @@ -416,8 +435,8 @@ describe('runV2Print', () => { await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr }); - const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed; - expect(seeds.some(([id]) => id === IAgentCatalogRuntimeOptions)).toBe(false); + const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput; + expect(input.args?.agentFiles ?? []).toEqual([]); }); it('passes --agent-file paths through unresolved so the engine can expand ~', async () => { @@ -434,9 +453,8 @@ describe('runV2Print', () => { { stdout, stderr }, ); - const seeds = mocks.bootstrap.mock.calls[0]?.[1] as ScopeSeed; - const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions); - expect(seeded?.[1]).toMatchObject({ explicitFiles: ['~/agents/reviewer.md'] }); + const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput; + expect(input.args?.agentFiles).toEqual(['~/agents/reviewer.md']); }); it('treats re-selecting the already-bound profile on resume as a no-op', async () => { diff --git a/apps/kimi-code/test/tui/components/dialogs/trust-prompt.test.ts b/apps/kimi-code/test/tui/components/dialogs/trust-prompt.test.ts new file mode 100644 index 0000000000..389b211493 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/trust-prompt.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { TrustPromptComponent } from '#/tui/components/dialogs/trust-prompt'; + +const ANSI_SGR = /\[[0-9;]*m/g; + +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +function renderLines(gatedMcpServers: readonly string[] = []): string[] { + const prompt = new TrustPromptComponent({ + workDir: '/tmp/demo-workspace', + gatedMcpServers, + onSelect: vi.fn(), + }); + return prompt.render(100).map(strip); +} + +describe('TrustPromptComponent', () => { + it('renders the header vocabulary and the workspace path', () => { + const lines = renderLines(); + const titleIdx = lines.findIndex((l) => l.includes('Trust this folder?')); + expect(titleIdx).toBeGreaterThanOrEqual(0); + const hint = lines[titleIdx + 1]; + expect(hint).toContain('↑↓ navigate'); + expect(hint).toContain('Enter select'); + expect(hint).toContain('Esc exit'); + expect(lines.some((l) => l.includes('/tmp/demo-workspace'))).toBe(true); + }); + + it('lists the gated project MCP servers when present', () => { + const lines = renderLines(['nested-server', 'root-server']); + expect(lines.some((l) => l.includes('This folder defines'))).toBe(true); + expect(lines.some((l) => l.includes('nested-server'))).toBe(true); + expect(lines.some((l) => l.includes('root-server'))).toBe(true); + expect(renderLines().some((l) => l.includes('This folder defines'))).toBe(false); + }); + + it('selects trust on Enter with the default highlight', () => { + const onSelect = vi.fn(); + const prompt = new TrustPromptComponent({ + workDir: '/tmp/demo-workspace', + gatedMcpServers: [], + onSelect, + }); + prompt.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('trust'); + }); + + it('selects distrust after moving the cursor down', () => { + const onSelect = vi.fn(); + const prompt = new TrustPromptComponent({ + workDir: '/tmp/demo-workspace', + gatedMcpServers: [], + onSelect, + }); + prompt.handleInput('\u001B[B'); + prompt.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('distrust'); + }); + + it('treats Esc as distrust', () => { + const onSelect = vi.fn(); + const prompt = new TrustPromptComponent({ + workDir: '/tmp/demo-workspace', + gatedMcpServers: [], + onSelect, + }); + prompt.handleInput('\u001B'); + expect(onSelect).toHaveBeenCalledWith('distrust'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index cf7184b3bd..f66c92e7d6 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -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; - } { - 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); - }); -}); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index b0689431ef..daaaa79e12 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -206,6 +206,7 @@ function makeHarness(session = makeSession(), overrides: Record track: vi.fn(), setTelemetryContext: vi.fn(), getExperimentalFeatures: vi.fn(async () => []), + supportsAtomicSectionReplace: vi.fn(() => false), auth: { status: vi.fn(async () => ({ providers: [] })), login: vi.fn(async () => {}), @@ -1140,6 +1141,121 @@ describe('KimiTUI startup', () => { expect(showStatus).toHaveBeenCalledWith("New Models · +2 models."); }); + it("stages provider-refresh removals and persists one atomic write on atomic-capable harnesses", async () => { + const registryUrl = "https://registry.example.test/v1/models/api.json"; + const source = { kind: "apiJson", url: registryUrl, apiKey: "sk-test-token" }; + const replaceConfigSections = vi.fn(async (_sections: Record) => {}); + const removeProvider = vi.fn(async () => ({})); + const setConfig = vi.fn(async () => ({})); + const harness = makeHarness(makeSession(), { + supportsAtomicSectionReplace: vi.fn(() => true), + replaceConfigSections, + removeProvider, + setConfig, + getConfig: vi.fn(async () => ({ + providers: { + a: { type: "openai", baseUrl: "https://a.example.test/v1", apiKey: "sk-test-token", source }, + b: { type: "openai", baseUrl: "https://b.example.test/v1", apiKey: "sk-test-token", source }, + }, + models: { + "a/m1": { provider: "a", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] }, + "b/m1": { provider: "b", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] }, + }, + defaultModel: "b/m1", + thinking: { enabled: true }, + })), + }); + const driver = makeDriver(harness, makeStartupInput()); + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + a: { + id: "a", + name: "Provider A", + api: "https://a.example.test/v1", + type: "openai", + models: { m1: { id: "m1" } }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ), + ); + try { + const result = await (driver as any).authFlow.refreshProviderModels(); + + expect(result.failed).toEqual([]); + expect(result.changed).toContainEqual({ providerId: "b", providerName: "b", added: 0, removed: 1 }); + // The removal was staged in memory: no destructive pre-write, exactly + // one atomic section replace carrying the complete records — with the + // dangling default model / thinking expressed as cleared sections. + expect(removeProvider).not.toHaveBeenCalled(); + expect(setConfig).not.toHaveBeenCalled(); + expect(replaceConfigSections).toHaveBeenCalledTimes(1); + const sections = replaceConfigSections.mock.calls[0]?.[0] as Record; + expect(Object.keys(sections["providers"] as object)).toEqual(["a"]); + expect(sections["models"]).not.toHaveProperty("b/m1"); + expect(sections["defaultModel"]).toBeUndefined(); + expect(sections["thinking"]).toBeUndefined(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("keeps the two-phase removeProvider/setConfig host on harnesses without atomic replace", async () => { + const registryUrl = "https://registry.example.test/v1/models/api.json"; + const source = { kind: "apiJson", url: registryUrl, apiKey: "sk-test-token" }; + const replaceConfigSections = vi.fn(async () => {}); + const removeProvider = vi.fn(async () => ({})); + const setConfig = vi.fn(async (patch: Record) => patch); + const harness = makeHarness(makeSession(), { + replaceConfigSections, + removeProvider, + setConfig, + getConfig: vi.fn(async () => ({ + providers: { + a: { type: "openai", baseUrl: "https://a.example.test/v1", apiKey: "sk-test-token", source }, + b: { type: "openai", baseUrl: "https://b.example.test/v1", apiKey: "sk-test-token", source }, + }, + models: { + "a/m1": { provider: "a", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] }, + "b/m1": { provider: "b", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] }, + }, + defaultModel: "b/m1", + })), + }); + const driver = makeDriver(harness, makeStartupInput()); + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + a: { + id: "a", + name: "Provider A", + api: "https://a.example.test/v1", + type: "openai", + models: { m1: { id: "m1" } }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ), + ); + try { + const result = await (driver as any).authFlow.refreshProviderModels(); + + expect(result.failed).toEqual([]); + expect(removeProvider).toHaveBeenCalledWith("b"); + expect(setConfig).toHaveBeenCalledTimes(1); + expect(replaceConfigSections).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + it("starts TUI without a session when fresh startup needs OAuth login", async () => { const harness = makeHarness(makeSession(), { createSession: vi.fn(async () => { diff --git a/apps/kimi-code/test/utils/catalog-fetch.test.ts b/apps/kimi-code/test/utils/catalog-fetch.test.ts new file mode 100644 index 0000000000..e609df8872 --- /dev/null +++ b/apps/kimi-code/test/utils/catalog-fetch.test.ts @@ -0,0 +1,84 @@ +import { DEFAULT_CATALOG_URL, CatalogFetchError } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { fetchCatalogOrBuiltIn } from '#/utils/catalog-fetch'; + +const BUILT_IN = JSON.stringify({ + anthropic: { + id: 'anthropic', + name: 'Anthropic', + models: { 'claude-test': { id: 'claude-test', limit: { context: 200000 } } }, + }, +}); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('fetchCatalogOrBuiltIn', () => { + it('returns the network catalog when models.dev is reachable', async () => { + const network = { openai: { id: 'openai', models: {} } }; + const fetchImpl = vi.fn(async () => jsonResponse(network)); + + const result = await fetchCatalogOrBuiltIn(DEFAULT_CATALOG_URL, { + fetchImpl: fetchImpl as unknown as typeof fetch, + builtInJson: BUILT_IN, + }); + + expect(result.fromBuiltIn).toBe(false); + expect(result.catalog).toEqual(network); + }); + + it('falls back to the built-in snapshot when the default URL fetch fails', async () => { + const fetchImpl = vi.fn(async () => jsonResponse('no', 503)); + + const result = await fetchCatalogOrBuiltIn(DEFAULT_CATALOG_URL, { + fetchImpl: fetchImpl as unknown as typeof fetch, + builtInJson: BUILT_IN, + }); + + expect(result.fromBuiltIn).toBe(true); + expect(result.catalog).toEqual(JSON.parse(BUILT_IN)); + }); + + it('does not fall back for a custom catalog URL', async () => { + const fetchImpl = vi.fn(async () => jsonResponse('no', 500)); + + await expect( + fetchCatalogOrBuiltIn('https://example.test/private.json', { + fetchImpl: fetchImpl as unknown as typeof fetch, + builtInJson: BUILT_IN, + }), + ).rejects.toBeInstanceOf(CatalogFetchError); + }); + + it('does not fall back when the caller aborted the request', async () => { + const controller = new AbortController(); + controller.abort(); + const fetchImpl = vi.fn(async () => { + throw new DOMException('Aborted', 'AbortError'); + }); + + await expect( + fetchCatalogOrBuiltIn(DEFAULT_CATALOG_URL, { + signal: controller.signal, + fetchImpl: fetchImpl as unknown as typeof fetch, + builtInJson: BUILT_IN, + }), + ).rejects.toThrow(); + }); + + it('rethrows when fetch fails and no built-in snapshot is available', async () => { + const fetchImpl = vi.fn(async () => jsonResponse('no', 500)); + + await expect( + fetchCatalogOrBuiltIn(DEFAULT_CATALOG_URL, { + fetchImpl: fetchImpl as unknown as typeof fetch, + builtInJson: '', + }), + ).rejects.toBeInstanceOf(CatalogFetchError); + }); +}); diff --git a/apps/kimi-inspect/src/App.tsx b/apps/kimi-inspect/src/App.tsx index bbb8c562f6..2624dcf54f 100644 --- a/apps/kimi-inspect/src/App.tsx +++ b/apps/kimi-inspect/src/App.tsx @@ -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 { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/workspace/sessionLifecycle/sessionLifecycle'; 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(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(ISessionLifecycleService).resume(sessionId); + }) .then(() => { if (!cancelled) setReady(true); }) @@ -104,6 +114,8 @@ export function App() { {view === 'services' ? ( + ) : view === 'workspace' ? ( + ) : view === 'bash' ? ( ) : view === 'models' ? ( diff --git a/apps/kimi-inspect/src/channel/channels.ts b/apps/kimi-inspect/src/channel/channels.ts index 508e6125fc..c2f28c0078 100644 --- a/apps/kimi-inspect/src/channel/channels.ts +++ b/apps/kimi-inspect/src/channel/channels.ts @@ -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( client: InspectClient, @@ -112,6 +113,10 @@ export function serviceByName( ): ServiceProxy | undefined { const id = createDecorator(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); diff --git a/apps/kimi-inspect/src/channel/client.ts b/apps/kimi-inspect/src/channel/client.ts index 3c9a0ca2b6..280f3ed829 100644 --- a/apps/kimi-inspect/src/channel/client.ts +++ b/apps/kimi-inspect/src/channel/client.ts @@ -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(ISessionLifecycleService).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(id: ServiceRef): ServiceProxy; + 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 { diff --git a/apps/kimi-inspect/src/components/ModelCatalogView.tsx b/apps/kimi-inspect/src/components/ModelCatalogView.tsx index 6b26f0b923..7d61f0f0a3 100644 --- a/apps/kimi-inspect/src/components/ModelCatalogView.tsx +++ b/apps/kimi-inspect/src/components/ModelCatalogView.tsx @@ -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 { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/workspace/sessionLifecycle/sessionLifecycle'; 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(ISessionLifecycleService) + .resume(sessionId); + } await klient .session(sessionId) .agent('main') diff --git a/apps/kimi-inspect/src/components/NavRail.tsx b/apps/kimi-inspect/src/components/NavRail.tsx index 126791c0ab..edcad9e00a 100644 --- a/apps/kimi-inspect/src/components/NavRail.tsx +++ b/apps/kimi-inspect/src/components/NavRail.tsx @@ -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[] = [ ), }, + { + id: 'workspace', + title: 'Workspace Services', + icon: ( + + + + ), + }, { id: 'bash', title: 'Bash Parser', diff --git a/apps/kimi-inspect/src/components/Sidebar.tsx b/apps/kimi-inspect/src/components/Sidebar.tsx index 5464cb1d27..dfbadfb264 100644 --- a/apps/kimi-inspect/src/components/Sidebar.tsx +++ b/apps/kimi-inspect/src/components/Sidebar.tsx @@ -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 { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/workspace/sessionLifecycle/sessionLifecycle'; 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(ISessionLifecycleService) + .resume(sessionId); + } await klient.session(sessionId).agent('main').service(IAgentProfileService).setModel(model); } } catch (error) { diff --git a/apps/kimi-inspect/src/components/WorkspaceDirBrowser.tsx b/apps/kimi-inspect/src/components/WorkspaceDirBrowser.tsx new file mode 100644 index 0000000000..e9aee3c409 --- /dev/null +++ b/apps/kimi-inspect/src/components/WorkspaceDirBrowser.tsx @@ -0,0 +1,211 @@ +/** + * WorkspaceDirBrowser — server-side filesystem browser living in the Workspace + * Services view's left sidebar, replacing the old