diff --git a/.agents/skills/agent-core-dev/align.md b/.agents/skills/agent-core-dev/align.md index 82e70fa3b..ae3d2a8b6 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 `workspaceHandler` (Workspace, one per live workspace handler). A v1 class that maps cleanly to one v1 decorator often becomes **three to five** v2 Services. That is expected and correct — do not try to keep the v1 class shape. diff --git a/.agents/skills/agent-core-dev/design.md b/.agents/skills/agent-core-dev/design.md index c9b4c2f0c..a7778e7b9 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: @@ -189,8 +191,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 — ``` @@ -225,47 +228,54 @@ Read it as: - `──holds──►` = the ancestor owns a handle to the child scope (it stores the key, not the service). DI allows this. - `accessor.get(...)` = a **runtime borrow**, not a dependency edge. It must cross an `IScopeHandle`, run on demand, never be cached, and finish before the child scope is disposed. -Worked example — `sessionLifecycle`: +Worked example — `workspaceHandler`: ```text -domain: `sessionLifecycle` (owning scope: App) +domain: `workspaceHandler` (owning scope: Workspace) ├─ serves (who uses me) -│ ├─ (inject) — (none yet) +│ ├─ (inject) — (none) │ └─ (accessor) │ ├─ sessionLegacy @App(edge) — v1-compatible create/fork/archive/… │ └─ gateway / rpc @App(edge) — native v2 session lifecycle actions ├─ exposes (interfaces I provide, by scope) -│ ├─ App : ISessionLifecycleService — owns the live session scope tree +│ ├─ Workspace : IWorkspaceHandlerService — owns this workspace's live session scope tree │ ├─ Session : — — (per-session state lives in sessionMetadata / agentLifecycle / …) │ └─ Agent : — — (per-agent state lives in agentLifecycle) └─ depends (what I inject) - ├─ bootstrap @App direct — addresses session storage - ├─ hostEnvironment @App direct — gates scope creation on the probe - ├─ sessionIndex @App direct — persisted read model for cold resumes - ├─ storage @App direct — atomic docs + append logs - ├─ workspace @App direct — resolves a session's workspace - └─ event @App direct — broadcasts session-level facts (e.g. archived) + ├─ workspaceContext @Workspace seed — handler identity + persistence scope + ├─ bootstrap @App direct — addresses session storage + ├─ hostEnvironment @App direct — gates scope creation on the probe + ├─ sessionIndex @App direct — persisted read model for cold resumes + ├─ storage @App direct — atomic docs + append logs + ├─ workspaceDirs / workspaceSkillCatalog / workspaceMcp / … + │ @Workspace direct — the handler's shared resource services + └─ event @App direct — broadcasts session-level facts (e.g. archived) ``` -Cross-scope borrow for `sessionLifecycle`: +Cross-scope borrow for `workspaceHandler`: ```text App scope - SessionLifecycleService ──holds──┐ - GatewayService ───────────holds──┼──► IScopeHandle(sessionId) - │ - │ accessor.get(ISessionMetadata) … - │ └── resolve runs inside the Session scope - ▼ - Session scope (sessionId) - sessionMetadata / agentLifecycle / … ← per-session services live here + WorkspaceLifecycleService ──holds──► IScopeHandle(workspaceId) (one per live handler) + │ + │ accessor.get(IWorkspaceHandlerService) + │ └── resolve runs inside the Workspace scope + ▼ + Workspace scope (workspaceId) + WorkspaceHandlerService ──holds──► IScopeHandle(sessionId) + │ + │ accessor.get(ISessionMetadata) … + │ └── resolve runs inside the Session scope + ▼ + Session scope (sessionId) + sessionMetadata / agentLifecycle / … ← per-session services live here ``` How the three lenses shaped it: -- **Scope (§2)** → the live registry of session scopes is process-wide, so it is App-scoped; per-session data stays in Session-scoped services, reached through the handle's `accessor`. -- **Dependency direction (§5)** → `sessionLifecycle` is consumed by the edge via `accessor` borrows; it never imports the edge. Every downward arrow lands on a peer or a more foundational Service. -- **Extension points (§4)** → new per-session behavior plugs into the Session-scoped services (`sessionMetadata`, `agentLifecycle`, `sessionActivity`); new transports stay at the edge. Neither edits `sessionLifecycle`. +- **Scope (§2)** → the live registry of one workspace's session scopes is per-handler, so it is Workspace-scoped; the process-wide handler registry lives in the App-scoped `workspaceLifecycle`; per-session data stays in Session-scoped services, reached through the handle's `accessor`. +- **Dependency direction (§5)** → `workspaceHandler` is consumed by the edge via `accessor` borrows; it never imports the edge. Every downward arrow lands on a peer or a more foundational Service. +- **Extension points (§4)** → new per-session behavior plugs into the Session-scoped services (`sessionMetadata`, `agentLifecycle`, `sessionActivity`); new transports stay at the edge. Neither edits `workspaceHandler`. For a multi-scope split, the `exposes` block fills more than one scope — see the `records` pattern in §3. diff --git a/.agents/skills/agent-core-dev/domain-boundaries.md b/.agents/skills/agent-core-dev/domain-boundaries.md index c2f62cc9c..8f97847a9 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 | `workspaceHandler` | Workspace-scope live handles, one registry per workspace handler (the process-wide handler registry is `workspaceLifecycle`); not the persisted entity table | | Session commands such as `archive()` | `session` | Orchestrates metadata, agent teardown, and events | | Persisted session list / get / count | `sessionIndex` | Backend-neutral read model | | Running / idle / awaiting status | `sessionActivity` | Derived from interactions and active turns; owns no state | diff --git a/.agents/skills/agent-core-dev/edge-exposure.md b/.agents/skills/agent-core-dev/edge-exposure.md index 738326d1a..db03a8e79 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' }, ... }, }; ``` @@ -83,14 +85,14 @@ Read = `GET`, write = `POST`. `sid` = `session_id`, `aid` = `agent_id`. | `session` | `setArchived` | ISessionMetadata.setArchived | POST | | `session` | `status` | ISessionActivity.status | GET | | `session` | `isIdle` | ISessionActivity.isIdle | GET | -| `session` | `archive` | ISessionLifecycleService.archive | POST | +| `session` | `archive` | IWorkspaceHandlerService.archive | POST | | `approvals` | `listPending` | IApprovalService.listPending | GET | | `approvals` | `decide` | IApprovalService.decide | POST | | `questions` | `listPending` | IQuestionService.listPending | GET | | `questions` | `answer` | IQuestionService.answer | POST | | `interactions` | `listPending` | IInteractionService.listPending | GET | | `interactions` | `respond` | IInteractionService.respond | POST | -| `workspace` | `setWorkDir` / `addAdditionalDir` / `removeAdditionalDir` / `resolve` | IWorkspaceContext.* | GET/POST | +| `workspace` | `workDir` / `additionalDirs` / `resolve` | ISessionWorkspaceContext.* | GET | ### Agent (`/api/v2/session/:sid/agent/:aid/:resource:action`) @@ -126,7 +128,7 @@ These fail §2 and must be wrapped in a facade that takes ids and returns data: | Service | Why not direct | Facade shape | |---|---|---| -| ISessionLifecycleService | returns `IScopeHandle` | `sessions.create` / `fork` / `close` / `archive` → wire Session | +| IWorkspaceHandlerService | returns `IScopeHandle` | `sessions.create` / `fork` / `close` / `archive` → wire Session | | IAgentPromptService / IAgentTurnService | returns `Turn` handle | `prompts.submit` / `steer` / `abort` / `undo` | | ILLMRequester | `AsyncIterable` stream | stream over WS, not RPC | | ISubagentHost | `SubagentHandle` | `subagents.spawn` / `resume` → info | diff --git a/.agents/skills/agent-core-dev/implement.md b/.agents/skills/agent-core-dev/implement.md index f8df820ed..1f860ab90 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 54f74f008..a16ccb28a 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, } ``` @@ -51,7 +53,7 @@ Deterministic: **child scopes die first; within one scope, instances dispose in The `Ln` in a file-header identity line is the domain's **dependency layer** (L0–L7), **not** its `LifecycleScope`. They are easy to confuse because both are small integers, but they answer different questions: -- `LifecycleScope` (App=0 / Session=1 / Agent=2) — **lifetime & visibility** (this stage). +- `LifecycleScope` (App=0 / Workspace=1 / Session=2 / Agent=3) — **lifetime & visibility** (this stage). - Dependency layer `Ln` (L0–L7) — **who may import whom**: a domain at layer `L` may import only domains at layer `<= L`. Enforced by `lint:domain` from the authoritative `DOMAIN_LAYER` map in `scripts/check-domain-layers.mjs`. So a Session-scoped service is not "L1" — e.g. `session` is Session-scoped but lives at **L6**. When you write the header, read the number from the layer map, not from the scope. @@ -73,7 +75,7 @@ So a Session-scoped service is not "L1" — e.g. `session` is Session-scoped but - **Header only.** Comments live solely in the top-of-file `/** */` block — never beside functions, methods, or statements. The code is the source of truth for *how*; the header states *what the module exposes and the responsibility it owns*. - **Identity line first.** Start with `` `` domain (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. +- **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`"). diff --git a/.agents/skills/agent-core-dev/service-authoring.md b/.agents/skills/agent-core-dev/service-authoring.md index 316ccf05c..5bd569a08 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). diff --git a/.changeset/fuzzy-pandas-refresh.md b/.changeset/fuzzy-pandas-refresh.md new file mode 100644 index 000000000..ecd821e6f --- /dev/null +++ b/.changeset/fuzzy-pandas-refresh.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix sporadic "model is not configured" errors when starting kimi web, caused by the background provider-model refresh transiently clearing the model catalog while the first session was being created. diff --git a/.changeset/kimi-web-markstream-monaco.md b/.changeset/kimi-web-markstream-monaco.md new file mode 100644 index 000000000..833432d49 --- /dev/null +++ b/.changeset/kimi-web-markstream-monaco.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Enable Monaco-based highlighting for code blocks, and fix line numbers overlapping or drifting out of alignment in fallback-rendered code blocks. diff --git a/.changeset/tui-drop-forced-full-redraws.md b/.changeset/tui-drop-forced-full-redraws.md new file mode 100644 index 000000000..90403031f --- /dev/null +++ b/.changeset/tui-drop-forced-full-redraws.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Reduce frequent full-screen redraws in the TUI. diff --git a/.changeset/vscode-login-waiting-wording.md b/.changeset/vscode-login-waiting-wording.md deleted file mode 100644 index e93bd1f45..000000000 --- a/.changeset/vscode-login-waiting-wording.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kimi-code": patch ---- - -Reword the sign-in waiting message from "Waiting for authorization" to "Waiting for authentication". diff --git a/AGENTS.md b/AGENTS.md index e6fe24046..9cd3a8dbf 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 `IWorkspaceHandlerService` 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, …) — Session/Agent never import the Workspace domain. 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 `IWorkspaceHandlerService`, and the fs routes resolve session → handler → the Workspace-scope fs services). 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/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index 3f8ccbcf7..456979bdf 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -32,7 +32,8 @@ import { IOAuthToolkit, ISessionCronService, ISessionIndex, - ISessionLifecycleService, + IWorkspaceHandlerService, + IWorkspaceLifecycleService, ITelemetryService, PRINT_MAX_TURNS_DEFAULT, PRINT_WAIT_CEILING_S_DEFAULT, @@ -41,6 +42,7 @@ import { bootstrap, createCloudAppender, ensureMainAgent, + resumeSessionById, hostRequestHeadersSeed, logSeed, parseAgentFileText, @@ -256,7 +258,7 @@ async function resolveNativeSession( defaultModel: string | undefined, stderr: PromptOutput, ): Promise { - const lifecycle = app.accessor.get(ISessionLifecycleService); + const workspaceLifecycle = app.accessor.get(IWorkspaceLifecycleService); const index = app.accessor.get(ISessionIndex); // `--agent` selects a catalog profile by name; otherwise `--agent-file` @@ -304,7 +306,7 @@ async function resolveNativeSession( }; const resumeById = async (id: string): Promise => { - const session = await lifecycle.resume(id); + const session = await resumeSessionById(app.accessor, id); if (session === undefined) { throw new Error(`Session "${id}" not found.`); } @@ -374,7 +376,8 @@ async function resolveNativeSession( } const model = requireConfiguredModel(opts.model, defaultModel); - const session = await lifecycle.create({ + const handler = await workspaceLifecycle.handlerFor({ root: workDir }); + const session = await handler.accessor.get(IWorkspaceHandlerService).create({ workDir, additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, mainAgentBinding: { 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 b1677e87b..51958dbe9 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/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 79c9f1d7d..59807b835 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -2052,11 +2052,10 @@ export class KimiTUI { this.state.todoPanelContainer.clear(); this.imageStore.clear(); this.renderWelcome(); - // Session resets (/new, /clear, session switch) want a pristine screen. - // Force a destructive full render: the renderer's collapse repaint - // intentionally preserves scrollback, which would leave the previous - // session's text above the welcome banner. - this.state.ui.requestRender(true); + // No forced full render on session reset: let the differential renderer + // converge on its own (a mass change above the viewport still makes the + // engine repaint everything, but nothing is forced destructively here). + this.state.ui.requestRender(); } private isTurnBoundaryComponent(child: Component): boolean { @@ -2550,12 +2549,10 @@ export class KimiTUI { if (!isExpandable(child)) continue; child.setExpanded(this.state.toolOutputExpanded && i >= expandCutoff); } - // Expanding/collapsing shifts content above the viewport; the clamped - // differential render would paint a second copy below the stale one in - // scrollback. This is a deliberate user action (like /clear), so do a - // destructive full render: scrollback holds exactly one copy and the - // expanded output can be read by scrolling up. - this.state.ui.requestRender(true); + // Differential render only — no destructive full redraw on expand/collapse. + // (When the expanded region reaches above the viewport, the engine's own + // fallback may still do a full render; that path is not forced from here.) + this.state.ui.requestRender(); } toggleTodoPanelExpansion(): void { @@ -2793,18 +2790,10 @@ export class KimiTUI { this.state.editorContainer.clear(); this.state.editorContainer.addChild(this.state.editor); this.state.ui.setFocus(this.state.editor); - // Measure overflow against the restored tree (editor mounted), not the tall - // panel just removed — otherwise a short session with a tall panel looks like - // it overflows and we take a full clear/home that yanks the editor to the top. - // Treat an exact one-screen fill as overflowing too: a full redraw is safe - // there (no blank tail) and clears a stale viewport offset after a shrink. - const { columns, rows } = this.state.terminal; - const overflowsViewport = this.state.ui.render(columns).length >= rows; - // Force a full re-render after replacing a tall panel with the shorter editor: - // differential rendering leaves the editor shifted up when the bottom-anchored - // region shrinks in place. Skip under tmux (its own reflow handles the shrink) - // and when content fits on one screen (a full clear would pull the editor up). - this.state.ui.requestRender(!this.state.terminalState.insideTmux && overflowsViewport); + // Differential render only: closing a tall panel leaves the editor a few + // rows above the bottom (blank tail) until the next append, but avoids a + // destructive full redraw on every dialog close. + this.state.ui.requestRender(); } restoreInputText(text: string): void { 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 27c664389..54ccbb052 100644 --- a/apps/kimi-code/test/cli/v2-run-print.test.ts +++ b/apps/kimi-code/test/cli/v2-run-print.test.ts @@ -20,7 +20,8 @@ import { IOAuthToolkit, ISessionCronService, ISessionIndex, - ISessionLifecycleService, + IWorkspaceHandlerService, + IWorkspaceLifecycleService, ISkillCatalogRuntimeOptions, ITelemetryService, type DomainEvent, @@ -179,6 +180,17 @@ function makeFakeHarness() { ]); const session = fakeScope('ses_v2', sessionServices); + const handlerServices = new Map([ + [ + IWorkspaceHandlerService, + { + create: vi.fn(async () => session), + resume: vi.fn(async () => session), + }, + ], + ]); + const workspace = fakeScope('wd_v2', handlerServices); + const appServices = new Map([ [ IConfigService, @@ -193,13 +205,25 @@ function makeFakeHarness() { }, ], [ - ISessionLifecycleService, + IWorkspaceLifecycleService, { - create: vi.fn(async () => session), - resume: vi.fn(async () => session), + handlerFor: vi.fn(async () => workspace), + }, + ], + [ + ISessionIndex, + { + list: vi.fn(async () => ({ items: [] })), + get: vi.fn(async (id: string) => ({ + id, + workspaceId: 'wd_v2', + cwd: process.cwd(), + createdAt: 1, + updatedAt: 1, + archived: false, + })), }, ], - [ISessionIndex, { list: vi.fn(async () => ({ items: [] })) }], [ IBootstrapService, { @@ -232,7 +256,7 @@ function makeFakeHarness() { ], ]); const app = fakeScope('app', appServices); - return { app, agent, session, agentServices, appServices, profileState }; + return { app, agent, session, agentServices, appServices, handlerServices, profileState }; } describe('runV2Print', () => { @@ -306,7 +330,7 @@ describe('runV2Print', () => { it('seeds explicit agent files from --agentFile and binds the --agent profile', async () => { const stdout = writer(); const stderr = writer(); - const { app, agent, appServices, agentServices } = makeFakeHarness(); + const { app, agent, appServices, agentServices, handlerServices } = makeFakeHarness(); mocks.bootstrap.mockReturnValue({ app }); mocks.ensureMainAgent.mockResolvedValue(agent); @@ -321,7 +345,7 @@ describe('runV2Print', () => { const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions); expect(seeded?.[1]).toMatchObject({ explicitFiles: ['/agents/reviewer.md'] }); - const lifecycle = appServices.get(ISessionLifecycleService) as { + const lifecycle = handlerServices.get(IWorkspaceHandlerService) as { create: ReturnType; }; expect(lifecycle.create).toHaveBeenCalledWith({ @@ -342,7 +366,7 @@ describe('runV2Print', () => { ); const stdout = writer(); const stderr = writer(); - const { app, agent, appServices, agentServices } = makeFakeHarness(); + const { app, agent, appServices, agentServices, handlerServices } = makeFakeHarness(); mocks.bootstrap.mockReturnValue({ app }); mocks.ensureMainAgent.mockResolvedValue(agent); @@ -356,7 +380,7 @@ describe('runV2Print', () => { const seeded = seeds.find(([id]) => id === IAgentCatalogRuntimeOptions); expect(seeded?.[1]).toMatchObject({ explicitFiles: [agentFile] }); - const lifecycle = appServices.get(ISessionLifecycleService) as { + const lifecycle = handlerServices.get(IWorkspaceHandlerService) as { create: ReturnType; }; expect(lifecycle.create).toHaveBeenCalledWith({ @@ -371,8 +395,8 @@ describe('runV2Print', () => { it('does not materialize a main agent after fresh profile binding fails', async () => { const stdout = writer(); const stderr = writer(); - const { app, appServices } = makeFakeHarness(); - const lifecycle = appServices.get(ISessionLifecycleService) as { + const { app, handlerServices } = makeFakeHarness(); + const lifecycle = handlerServices.get(IWorkspaceHandlerService) as { create: ReturnType; }; lifecycle.create.mockRejectedValueOnce(new Error('Unknown agent profile')); 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 cf7184b3b..f66c92e7d 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-inspect/src/App.tsx b/apps/kimi-inspect/src/App.tsx index bbb8c562f..bb634b04f 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 { IWorkspaceHandlerService } from '@moonshot-ai/agent-core-v2/workspace/workspaceHandler/workspaceHandler'; import { useEffect, useState } from 'react'; import type { AuditTrail } from './audit/trail'; @@ -28,6 +31,7 @@ import { SearchView } from './components/SearchView'; import { ServerSwitcher } from './components/ServerSwitcher'; import { SessionPane } from './components/SessionPane'; import { Sidebar } from './components/Sidebar'; +import { WorkspaceServicesView } from './components/WorkspaceServicesView'; import { useConnection } from './connection'; import type { SearchHit } from './search/api'; import { errorMessage } from './ui'; @@ -45,15 +49,21 @@ export function App() { const [jump, setJump] = useState(null); // Resume (materialize) the session on the server when it is selected, so - // session / agent scoped Services become reachable. + // session / agent scoped Services become reachable. Session lifecycle lives + // on the workspace handler (Workspace scope): the index yields the + // session's workspaceId, then the handler resumes it. useEffect(() => { if (sessionId === null) return; let cancelled = false; setReady(false); setResumeError(null); klient - .core(ISessionLifecycleService) - .resume(sessionId) + .core(ISessionIndex) + .get(sessionId) + .then((summary) => { + if (summary === undefined) throw new Error(`session ${sessionId} does not exist`); + return klient.workspace(summary.workspaceId).service(IWorkspaceHandlerService).resume(sessionId); + }) .then(() => { if (!cancelled) setReady(true); }) @@ -104,6 +114,8 @@ export function App() { {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 508e6125f..c2f28c007 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 3c9a0ca2b..6f3b36458 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(IWorkspaceHandlerService).resume('s1'); * await client.session('s1').service(ISessionMetadata).read(); * await client.session('s1').agent('main').service(IAgentRPCService).cancel({}); * @@ -39,6 +40,7 @@ export interface InspectClient { /** Bearer token in use, when any. */ readonly token?: string; core(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 6b26f0b92..49a592b5d 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 { IWorkspaceHandlerService } from '@moonshot-ai/agent-core-v2/workspace/workspaceHandler/workspaceHandler'; import type { InspectionSource } from '@moonshot-ai/agent-core-v2/kosong/contract/inspection'; import type { TokenUsage } from '@moonshot-ai/agent-core-v2/kosong/contract/usage'; import { @@ -405,7 +406,13 @@ function ModelSection({ const envelope = (await res.json()) as { code: number; msg: string; data: { id: string } }; if (envelope.code !== 0) throw new Error(envelope.msg); const sessionId = envelope.data.id; - await klient.core(ISessionLifecycleService).resume(sessionId); + const summary = await klient.core(ISessionIndex).get(sessionId); + if (summary !== undefined) { + await klient + .workspace(summary.workspaceId) + .service(IWorkspaceHandlerService) + .resume(sessionId); + } await klient .session(sessionId) .agent('main') diff --git a/apps/kimi-inspect/src/components/NavRail.tsx b/apps/kimi-inspect/src/components/NavRail.tsx index 126791c0a..edcad9e00 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 5464cb1d2..69461161f 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 { IWorkspaceHandlerService } from '@moonshot-ai/agent-core-v2/workspace/workspaceHandler/workspaceHandler'; import { IWorkspaceService, type Workspace, @@ -109,7 +109,13 @@ export function Sidebar({ try { const model = await resolveDefaultModel(klient); if (model !== undefined) { - await klient.core(ISessionLifecycleService).resume(sessionId); + const summary = await klient.core(ISessionIndex).get(sessionId); + if (summary !== undefined) { + await klient + .workspace(summary.workspaceId) + .service(IWorkspaceHandlerService) + .resume(sessionId); + } await klient.session(sessionId).agent('main').service(IAgentProfileService).setModel(model); } } catch (error) { diff --git a/apps/kimi-inspect/src/components/WorkspaceDirBrowser.tsx b/apps/kimi-inspect/src/components/WorkspaceDirBrowser.tsx new file mode 100644 index 000000000..e9aee3c40 --- /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